@redue/cli 0.1.0-alpha.25 → 0.1.0-alpha.26

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 CHANGED
@@ -1,5 +1,49 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.0-alpha.26 — 2026-10-31
4
+
5
+ Typecheck and lint checks run inside an OS sandbox, so REDUE can reuse them
6
+ for native tools and for Python. Python projects are supported for the first
7
+ time.
8
+
9
+ - **Sandboxed recipe.** On macOS (Seatbelt) and Linux 6.7+ (Landlock), a
10
+ root typecheck or lint script runs in a sandbox that sees only the
11
+ project, its installed packages and the toolchain, the way CI does. Its
12
+ inputs are the whole checkout, git-ignored files included, plus the
13
+ installed packages and a fingerprint of the environment it was given. A
14
+ result holds for a run that sees only those. `init` tries the precise
15
+ TypeScript recipes first, then the sandbox, then the general recipe. See
16
+ [sandboxed recipe](docs/user/sandbox.md).
17
+ - **Python.** `redue setup claude` and `init` find mypy, pyright, ty and
18
+ ruff in the project's `.venv`, from its CI workflows, Makefile, justfile,
19
+ tox, nox and scripts. The Claude Code hook recognizes `uv run`,
20
+ `poetry run`, `python -m` and `.venv/bin` forms.
21
+ - **What the sandbox changes.** Network is off. HOME and the temp folder
22
+ are private to the check; HOME is kept between runs so caches persist.
23
+ Tools can read `.git`, but git commands find no repository. A background
24
+ process the check leaves running is stopped when the run ends.
25
+ - **Checked at init.** `init` runs each check plainly and sandboxed, and
26
+ proposes the sandbox only when exit status and output (timings and paths
27
+ aside) match. Otherwise the general recipe applies. A failing run whose
28
+ output looks like a sandbox refusal is rerun plainly; if the plain run
29
+ exits differently, the result is UNVERIFIED. Other failures run once.
30
+ - **Coverage.** In a census of 120 public TypeScript repositories (86
31
+ installed on macOS), 35 have a qualifying check (alpha.24 census: 30):
32
+ typecheck 22, lint 25, tests 2. Of 120 Python repositories, 23 of the 75
33
+ that installed on Linux have one (typecheck 15, lint 22); 20 of 64 on
34
+ macOS. On Linux, every sandboxed run was traced with strace: each file
35
+ it read in the checkout was a recorded input, a cache or its own output.
36
+ - **Measured with Claude Code.** On starlette, graphon and
37
+ claude-agent-sdk-python, sessions took 58 s with REDUE and 125 s without.
38
+ REDUE skipped 10 typechecks and every one matched a plain rerun.
39
+ - Known limits: inside Claude Code's own macOS sandbox and on Windows, the
40
+ sandbox can't start and the general recipe applies. On Linux before 6.7,
41
+ sandboxed runs are UNVERIFIED.
42
+ A build or test run that writes into the checkout (`dist/`, `.next/`,
43
+ coverage files) makes a sandboxed check STALE, as it does a
44
+ general-recipe check: about 1 in 4 TypeScript and 2 in 3 Python checks
45
+ stayed CURRENT through the project's build and tests.
46
+
3
47
  ## 0.1.0-alpha.25 — 2026-10-29
4
48
 
5
49
  Running your tests no longer makes a typecheck STALE, and `status --sync`
package/README.md CHANGED
@@ -10,9 +10,11 @@ check that still holds is not run again and one that is stale is.
10
10
 
11
11
  ## Quickstart (about 5 minutes)
12
12
 
13
- You need macOS or Linux, Node 22 or 24, Git, Claude Code, and a JavaScript or
14
- TypeScript project with its dependencies installed (`npm install`,
15
- `pnpm install` or `yarn`). On Linux, also Python 3.
13
+ You need macOS or Linux, Node 22 or 24, Git, Claude Code, and either a
14
+ JavaScript or TypeScript project with its dependencies installed
15
+ (`npm install`, `pnpm install` or `yarn`), or a Python project with a `.venv`
16
+ in its root (`uv sync`, or Poetry with in-project virtualenvs). On Linux, also
17
+ Python 3.
16
18
 
17
19
  ```sh
18
20
  npm install --global @redue/cli@alpha
@@ -62,21 +64,27 @@ To undo everything setup did: `redue remove`.
62
64
  ## What it covers today
63
65
 
64
66
  In a census of 120 active public TypeScript repositories (October 2026, REDUE
65
- at commit 0fcd19b, after 0.1.0-alpha.23), each installed as its users would,
66
- with dependencies' install scripts on, 86 installed cleanly. A check counts
67
- when two recorded runs on the same code both came out CURRENT on the same
68
- inputs. 30 repositories had at least one such check:
67
+ at commit 046cd0e, before 0.1.0-alpha.26), each installed as its users would,
68
+ with dependencies' install scripts on, 86 installed cleanly on a macOS runner.
69
+ A check counts when two recorded runs on the same code both came out CURRENT
70
+ on the same inputs. 35 repositories had at least one such check:
69
71
 
70
72
  | Check | Repositories | How |
71
73
  |---|---|---|
72
- | Typecheck | 21 | 15 by running the project's script with every Node process recorded ([general recipe](docs/user/node-script.md)); 6 by running the local `tsc` directly ([direct recipe](docs/user/direct-typescript.md)) |
73
- | Lint | 18 | general recipe |
74
+ | Typecheck | 22 | 15 by running the project's script in an OS sandbox ([sandboxed recipe](docs/user/sandbox.md)); 5 by running the local `tsc` directly ([direct recipe](docs/user/direct-typescript.md)); 2 by running the script with every Node process recorded ([general recipe](docs/user/node-script.md)) |
75
+ | Lint | 25 | 21 sandboxed recipe, 4 general recipe |
74
76
  | Tests | 2 | Vitest run directly ([direct test recipe](docs/user/tests-direct.md)) |
75
77
 
78
+ In a census of 120 Python repositories, 75 installed on a Linux runner
79
+ and 23 had a qualifying check: typecheck (mypy, pyright, ty) 15, lint (ruff)
80
+ 22, all through the sandboxed recipe. On Linux, every sandboxed run was traced
81
+ with strace: each file it read in the checkout was a recorded input, a cache
82
+ or its own output.
83
+
76
84
  Everything else stays UNVERIFIED, with a reason you can read in
77
85
  `redue explain`. The usual ones: a script starts a program REDUE cannot see
78
- into (esbuild, turbo, nx, oxlint's tsgolint, TypeScript 7's native compiler),
79
- the project uses Bun, or a test suite writes into its own inputs.
86
+ into and the sandbox can't run (inside Claude Code's macOS sandbox, on
87
+ Windows), the project uses Bun, or a check writes into its own inputs.
80
88
  [Supported projects](docs/user/supported-projects.md) has the details.
81
89
 
82
90
  ## Measured with Claude Code
package/bin/redue.mjs CHANGED
@@ -7,6 +7,7 @@ import {directCompiler,directQualification,directEnvironment,directInstalledInpu
7
7
  import {runArgv,tscRun} from '../src/typescript-invocation.mjs';
8
8
  import {testEnvironment,testRunnerAt,testsInterpretation,testsQualification} from '../src/test-direct.mjs';
9
9
  import {scriptEnvironment,scriptInterpretation,scriptKinds,scriptQualification} from '../src/node-script.mjs';
10
+ import {pythonTool,sandboxEnvironment,sandboxInterpretation,sandboxKinds,sandboxQualification} from '../src/sandbox.mjs';
10
11
  import os from 'node:os';
11
12
  import path from 'node:path';
12
13
  import {createHash} from 'node:crypto';
@@ -31,7 +32,7 @@ Commands:
31
32
  setup claude [--yes] Set up REDUE and Claude Code in this project in one step:
32
33
  init, agent setup claude, start, one recorded run per check
33
34
  remove [--yes] Undo setup: integration, observer state, and the config setup created
34
- init [--dry-run] [--workspace NAME] [--check KIND] [--recipe typescript-direct|tests-direct|node-script]
35
+ init [--dry-run] [--workspace NAME] [--check KIND] [--recipe typescript-direct|tests-direct|node-script|sandbox]
35
36
  Discover existing scripts; optionally select one workspace/check
36
37
  agent setup codex|claude [--dry-run|--apply] [--json]
37
38
  agent doctor [codex|claude] [--json]
@@ -119,9 +120,28 @@ if(short&&(command!=='status'||json||sync))error('--short is a cached status opt
119
120
  if(dryRun&&command!=='init')error('--dry-run is for init');
120
121
 
121
122
  if(command==='init'){
122
- const {discoverNodeProject}=await import('../src/node-onboarding.mjs');
123
- let discovery;try{discovery=discoverNodeProject(process.cwd(),{recipe:recipeSelection});}
124
- catch(e){error(e.message);}
123
+ const {discoverNodeProject,sourceLinkIssue}=await import('../src/node-onboarding.mjs');
124
+ const {discoverPythonProject}=await import('../src/python-onboarding.mjs');
125
+ const {sandboxTrial}=await import('../src/sandbox.mjs');
126
+ // A Node project, a Python project, or both in one checkout.
127
+ const python=!recipeSelection||recipeSelection==='sandbox'?discoverPythonProject(process.cwd()):null;
128
+ let discovery;
129
+ if(fs.existsSync('package.json')||!python)try{discovery=discoverNodeProject(process.cwd(),{recipe:recipeSelection});}
130
+ catch(e){error(e.message);}
131
+ else{const root=realObservedPath(process.cwd());
132
+ const git=spawnSync('git',['rev-parse','--show-toplevel'],{cwd:root,encoding:'utf8',timeout:3000,stdio:['ignore','pipe','ignore']});
133
+ discovery={schema:1,root,manager:{name:null,version:null,layout:null},observationIssue:git.status===0?sourceLinkIssue(root):
134
+ 'Git checkout is unavailable; filesystem observation needs a checkout',workspaces:false,node:process.version,
135
+ checks:[],ambiguous:[],skipped:[],config:{schema:1,checks:[]}};}
136
+ if(python){
137
+ // A Python check takes its tool's name where the name is taken.
138
+ const taken=new Set(discovery.checks.map(row=>row.config.name));
139
+ for(const row of python.checks){if(taken.has(row.config.name))row.config.name=`${row.config.kind}:${row.config.command[0].split('/').at(-1)}`;
140
+ taken.add(row.config.name);}
141
+ if(!discovery.observationIssue)discovery.checks.push(...python.checks);
142
+ discovery.skipped.push(...python.skipped);
143
+ discovery.python=true;
144
+ }
125
145
  if(workspaceSelection||checkSelection){
126
146
  const chosen=discovery.checks.filter(row=>
127
147
  (!workspaceSelection||row.config.workspace===workspaceSelection||
@@ -130,6 +150,26 @@ if(command==='init'){
130
150
  if(!chosen.length)error('no discovered check matches the selected workspace and kind');
131
151
  discovery.checks=chosen;discovery.config.checks=chosen.map(row=>row.config);
132
152
  }
153
+ // A sandboxed check is proposed only when it gives the same exit status
154
+ // inside the sandbox as outside it. Otherwise init writes what it would
155
+ // have without the sandboxed recipe, with the reason.
156
+ let trials=0;
157
+ for(const [at,row] of [...discovery.checks.entries()]){
158
+ if(row.qualification!==sandboxQualification||row.level!=='ready')continue;
159
+ if(!json)console.error(`Running ${row.config.name} inside and outside the sandbox to confirm it...`);
160
+ const trial=await sandboxTrial(discovery.root,row.config);
161
+ if(!trial.issue)trials++;
162
+ const issue=trial.issue?`the sandbox cannot run here (${trial.issue})`:
163
+ // A failure both ways that shows a refusal cannot be told from a real one.
164
+ trial.same?null:trial.plain!==trial.sandboxed?`exit ${trial.sandboxed} inside the sandbox, ${trial.plain} outside it${trial.line?` (${trial.line})`:''}`:
165
+ trial.line?`it fails both ways (exit ${trial.plain}), with a sign of a refusal inside the sandbox (${trial.line})`:
166
+ `the output differs inside the sandbox: ${JSON.stringify(trial.output.sandboxed)} instead of ${JSON.stringify(trial.output.plain)}`;
167
+ if(!issue){row.reason+=`; ran both ways at init, exit ${trial.plain}`;continue;}
168
+ if(row.fallback)discovery.checks[at]={...row.fallback,reason:`Sandboxed recipe unavailable: ${issue}; ${row.fallback.reason}`};
169
+ else{discovery.checks[at]=null;discovery.skipped.push({kind:row.config.name,reason:`${row.config.name}: sandboxed recipe unavailable: ${issue}`});}
170
+ }
171
+ discovery.checks=discovery.checks.filter(Boolean).map(({fallback,...row})=>row);
172
+ discovery.config.checks=discovery.checks.map(row=>row.config);
133
173
  const configured=fs.existsSync(configFile),unsupported=Boolean(discovery.observationIssue||
134
174
  discovery.manager.issue&&!['npm','yarn','pnpm'].includes(discovery.manager.name)||
135
175
  discovery.manager.name==='yarn'&&!discovery.manager.version);
@@ -144,8 +184,9 @@ if(command==='init'){
144
184
  workspace:check.workspace||null,level,reason})),config:configFile,
145
185
  written:willWrite,existing:configured,writable:!configured&&!unsupported&&discovery.checks.length>0};
146
186
  if(!json) {
147
- console.log(`Node ${summary.node}; ${summary.package_manager||'unknown package manager'}`+
187
+ if(summary.package_manager||!discovery.python)console.log(`Node ${summary.node}; ${summary.package_manager||'unknown package manager'}`+
148
188
  `${summary.version?' '+summary.version:''}; ${summary.installation_layout} installation.`);
189
+ if(discovery.python)console.log('Python project; tools run from .venv.');
149
190
  if(summary.issue)console.log(`Boundary: ${summary.issue}`);
150
191
  for(const row of summary.checks){
151
192
  console.log(` ${row.name} (${row.script||row.command?.join(' ')}) — `+
@@ -167,7 +208,7 @@ if(command==='init'){
167
208
  if(!samePath(realObservedPath(path.dirname(configFile)),discovery.root))
168
209
  error('init writes only at the repository root; use --config there or --dry-run');
169
210
  fs.writeFileSync(configFile,JSON.stringify(discovery.config,null,2)+'\n',{flag:'wx'});
170
- if(!json){console.log(`Created ${configFile}. No checks were executed.`);
211
+ if(!json){console.log(`Created ${configFile}. ${trials?'Sandboxed checks ran both ways to confirm them; no results were recorded.':'No checks were executed.'}`);
171
212
  console.log('Ready to qualify means the supported contract will be validated; it is not verification evidence.');
172
213
  console.log(`Next: review the config, then redue start and redue run ${JSON.stringify(discovery.checks[0].config.name)}.`);
173
214
  console.log('Connect your agent: redue agent setup codex (or claude). Preview with --dry-run.');
@@ -269,6 +310,7 @@ const checks=publicConfig.checks.map(check=>{
269
310
  const pnpmTypecheck=check.qualification==='pnpm-tsc-v1';
270
311
  const testsDirect=check.qualification===testsQualification;
271
312
  const generalScript=check.qualification===scriptQualification;
313
+ const sandboxed=check.qualification===sandboxQualification;
272
314
  if(check.script&&check.command&&!pnpmTypecheck&&!npmDirect&&!testsDirect)
273
315
  error(`${check.name}: choose script or command, not both`);
274
316
  const manager=publicConfig.packageManager;
@@ -278,12 +320,16 @@ const checks=publicConfig.checks.map(check=>{
278
320
  [`@which:${manager}`,'run',check.script]:null;
279
321
  const npmAutomatic=check.qualification==='typescript-noemit-v1';
280
322
  const yarnWorkspace=check.qualification==='yarn-workspace-tsc-v1';
281
- if(check.qualification&&!npmAutomatic&&!npmDirect&&!yarnWorkspace&&!pnpmTypecheck&&!testsDirect&&!generalScript)
323
+ if(check.qualification&&!npmAutomatic&&!npmDirect&&!yarnWorkspace&&!pnpmTypecheck&&!testsDirect&&!generalScript&&!sandboxed)
282
324
  error(`${check.name}: unknown qualification contract`);
283
325
  // The general recipe runs the root package script through the package manager.
284
326
  if(generalScript&&(!['npm','pnpm','yarn'].includes(manager)||!check.script||check.command||check.cwd&&check.cwd!=='.'||
285
327
  check.workspace||!scriptKinds.includes(check.kind)))
286
328
  error(`${check.name}: the general recipe needs a root typecheck, test or lint script, without command or cwd`);
329
+ // The sandboxed recipe runs a root package script, or a tool from the project's .venv.
330
+ if(sandboxed&&(check.cwd&&check.cwd!=='.'||check.workspace||!sandboxKinds.includes(check.kind)||
331
+ (check.script?!['npm','pnpm','yarn'].includes(manager):!pythonTool(check))))
332
+ error(`${check.name}: the sandboxed recipe needs a root ${sandboxKinds.join(' or ')} script, or a command that runs a tool from .venv/bin, without cwd`);
287
333
  if(npmDirect&&(manager!=='npm'||check.cwd&&check.cwd!=='.'||!Array.isArray(check.command)||
288
334
  check.command[0]!=='@node'||check.command[1]!=='@typescript-compiler:.'||
289
335
  !tscRun({},{script:null,args:check.command.slice(2)}).run?.noEmit))
@@ -324,7 +370,8 @@ const checks=publicConfig.checks.map(check=>{
324
370
  check.workspace||'.',...run]]:[];
325
371
  const windowsContext=process.platform==='win32'?
326
372
  ['ComSpec','COMSPEC','PATHEXT','USERPROFILE']:[];
327
- const autoEnvironment=npmDirect?directEnvironment:testsDirect?testEnvironment:generalScript?scriptEnvironment(manager):npmAutomatic?{variables:['NODE_OPTIONS','NODE_PATH','CI','HOME',
373
+ const autoEnvironment=npmDirect?directEnvironment:testsDirect?testEnvironment:generalScript?scriptEnvironment(manager):
374
+ sandboxed?sandboxEnvironment(Boolean(pythonTool(check)),check.script?manager:null):npmAutomatic?{variables:['NODE_OPTIONS','NODE_PATH','CI','HOME',
328
375
  'NODE_ENV','BASH_ENV','ENV',...windowsContext],prefixes:['npm_config_','DYLD_','TSGO_'],
329
376
  pathExecutables:['node','npm'],executableIdentity:true}:yarnWorkspace?
330
377
  {variables:['NODE_OPTIONS','NODE_PATH','CI','HOME','NODE_ENV','BASH_ENV','ENV',...windowsContext],
@@ -351,6 +398,7 @@ const checks=publicConfig.checks.map(check=>{
351
398
  ...(npmDirect?{qualificationInterpretation:directInterpretation}:{}),
352
399
  ...(testsDirect?{qualificationInterpretation:testsInterpretation}:{}),
353
400
  ...(generalScript?{qualificationInterpretation:scriptInterpretation}:{}),
401
+ ...(sandboxed?{qualificationInterpretation:sandboxInterpretation}:{}),
354
402
  workspace:check.workspace||null,kind:check.kind||null};
355
403
  });
356
404
  const runtime={schema:1,provider:'declared-project@1',root,state,checks};
package/docs/README.md CHANGED
@@ -5,6 +5,7 @@ For users:
5
5
  - [Install, initialize, run, and remove](user/getting-started.md)
6
6
  - [Fixed-build opt-in development pilot](user/pilot.md)
7
7
  - [Supported projects and conservative boundaries](user/supported-projects.md)
8
+ - [Sandboxed recipe: typecheck and lint in any language, Python included](user/sandbox.md)
8
9
  - [Configuration](user/configuration.md)
9
10
  - [Connect coding agents and apply the decision policy](user/agents.md)
10
11
  - [JSON contract](user/json.md)
@@ -4,10 +4,12 @@ The general recipe records a project's own typecheck or lint script, run
4
4
  through its package manager, and reports later whether that result still
5
5
  applies to the files you have. It is a lower tier than the strict recipes:
6
6
  `redue init` tries the [direct TypeScript recipe](direct-typescript.md), the
7
- pnpm and Yarn TypeScript recipes and the [direct test recipe](tests-direct.md)
8
- first, and uses the general recipe for a root typecheck or lint script none of
9
- them takes. Test scripts use the direct test recipe; `init` does not propose
10
- the general recipe for them.
7
+ pnpm and Yarn TypeScript recipes, the [direct test recipe](tests-direct.md)
8
+ and, on macOS and Linux, the [sandboxed recipe](sandbox.md) first. It uses
9
+ the general recipe for a root typecheck or lint script none of them takes,
10
+ and where the sandbox cannot run (at init, or at run time inside Claude
11
+ Code's macOS sandbox). Test scripts use the direct test recipe; `init` does
12
+ not propose the general recipe for them.
11
13
 
12
14
  It supports a project installed by npm, pnpm 9-12 (isolated or hoisted
13
15
  `node_modules`) or Yarn with the `node-modules` linker, with one lockfile kind
@@ -121,8 +123,10 @@ recording-only.
121
123
  Anything else makes the result UNVERIFIED and names the program: `git`,
122
124
  `sleep`, `find`, and native programs from installed packages such as esbuild,
123
125
  turbo, tsgo, tsgolint and biome, whose reads, writes and network use REDUE
124
- cannot see. `init` reads the script the same way and keeps a script that
125
- starts such a program recording-only, with the program in the reason.
126
+ cannot see. `init` reads the script the same way. A typecheck or lint script
127
+ gets the [sandboxed recipe](sandbox.md) first where it can run, which covers
128
+ such programs; otherwise a script that starts one stays recording-only, with
129
+ the program in the reason.
126
130
 
127
131
  A command line may use `&&`, `||`, `;`, `|`, quotes without expansions,
128
132
  globs in arguments and redirection to `/dev/null`. Variables, command
@@ -0,0 +1,169 @@
1
+ # Sandboxed recipe (typecheck and lint, any language)
2
+
3
+ The sandboxed recipe runs a typecheck or lint command inside an operating
4
+ system sandbox and reports later whether that result still applies to the
5
+ files you have. The sandbox holds for every process the command starts, so it
6
+ covers programs REDUE cannot record: TypeScript 7's native compiler, esbuild,
7
+ oxlint and tsgolint, biome, turbo, and Python tools such as mypy, pyright, ty
8
+ and ruff.
9
+
10
+ **The claim.** A sandboxed result holds for a run that sees only the project,
11
+ its installed packages and the toolchain, the way CI does. It does not say
12
+ that a run with your own configuration files, a service on your machine or the
13
+ network would give the same result.
14
+
15
+ ## Where it applies
16
+
17
+ - **Node projects:** a root `typecheck` or `lint` script (npm, pnpm, or Yarn
18
+ with the `node-modules` linker). `init` tries the
19
+ [direct TypeScript recipe](direct-typescript.md) and the pnpm and Yarn
20
+ TypeScript recipes first; they stay first where they apply. Then the
21
+ sandboxed recipe, before the [general recipe](node-script.md), since it
22
+ also covers the native programs a script starts. Where init's trial shows a
23
+ difference, or the sandbox cannot run, `init` writes the general recipe as
24
+ before. Where the sandbox cannot start at run time (inside Claude Code's
25
+ macOS sandbox), a script the general recipe takes is recorded as it records
26
+ it.
27
+ - **Python projects:** mypy, pyright, ty and ruff installed in the project's
28
+ `.venv` (`uv sync` makes one; Poetry does with `virtualenvs.in-project`).
29
+ `init` uses the arguments your CI workflow, Makefile, tox, nox, justfile or
30
+ shell scripts in `scripts/` run the tool with, through `uv run`, `poetry run`,
31
+ `python -m` or `.venv/bin`. Without them it uses the tool's default:
32
+ `ruff check`, `ty check`, `pyright`, and `mypy` (with `.` when mypy's
33
+ configuration names no `files`).
34
+ - **Platforms:** macOS (Seatbelt, `sandbox-exec`) and Linux 6.7 or later
35
+ (Landlock; the launcher needs Python 3).
36
+
37
+ ```sh
38
+ redue init
39
+ redue start
40
+ redue run typecheck
41
+ redue status --sync
42
+ ```
43
+
44
+ A Python check in the config:
45
+
46
+ ```json
47
+ {"name": "typecheck", "kind": "typecheck", "qualification": "sandbox-v1",
48
+ "command": [".venv/bin/mypy", "src"], "inputs": ["**", "..."], "installedInputs": [".venv/**", "..."]}
49
+ ```
50
+
51
+ A Node check names its script, as the general recipe's do:
52
+ `{"name": "lint", "script": "lint", "kind": "lint", "qualification": "sandbox-v1", ...}`.
53
+
54
+ With the Claude Code hook, `uv run mypy src`, `poetry run mypy src`,
55
+ `python -m mypy src`, `.venv/bin/mypy src` and `mypy src` are recognized as
56
+ that check. The last two count only when PATH finds the tool or Python in the
57
+ project's `.venv/bin` first. When the hook runs the check through REDUE, it
58
+ keeps `uv run` or `poetry run` in front, so the environment still syncs first.
59
+
60
+ ## What the run sees
61
+
62
+ It may read and execute only beneath:
63
+
64
+ - the checkout;
65
+ - the toolchain: Node's installation and the package managers' (with
66
+ Corepack's downloads), or the Python installation the `.venv` was made from;
67
+ - system directories (`/usr`, `/bin`, `/etc` and the like on Linux; `/usr`,
68
+ `/System`, `/Library`, `/opt/homebrew` and the like on macOS);
69
+ - a private home directory and a fresh temporary directory, in REDUE's state.
70
+
71
+ It has no network: no TCP connection, loopback included. On macOS its only
72
+ Unix sockets are in its own temporary directory, which is new for each run.
73
+ On Linux 6.12 and later it cannot reach an abstract Unix socket outside the
74
+ run.
75
+
76
+ What the check leaves running is stopped when it exits, as at the end of a
77
+ CI job: its process group gets SIGTERM, and SIGKILL a second later. On Linux
78
+ the launcher also stops daemons that started their own session. On macOS
79
+ such a daemon outlives the run, but no later run can reach it: its socket
80
+ was in a temporary directory that is gone. The run gets no terminal input.
81
+
82
+ The checkout's `.git` is not an input. Tools still see it, as they do in a CI
83
+ checkout (a linter that applies `.gitignore` only inside a repository does),
84
+ but git commands find no repository: `GIT_DIR` points nowhere. A script that
85
+ runs git (`git diff --exit-code`) gives a different result inside the
86
+ sandbox, and `init` leaves it to the other recipes.
87
+
88
+ It gets a controlled environment, not yours:
89
+
90
+ - the variables the check declares (`TZ`, `LANG`, `CI`, `USER`, `LC_*`,
91
+ `DYLD_*`, `LD_*`, and `NODE_*`, `npm_config_*`, `PNPM_*`, `YARN_*`,
92
+ `COREPACK_*` for Node, or `PYTHON*`, `MYPY*`, `RUFF_*`, `PYRIGHT*`, `TY_*`
93
+ for Python), with your values;
94
+ - your PATH, without the project's `.venv/bin`, which a Python check gets
95
+ first instead, so `uv run` and an activated environment give the same PATH;
96
+ - `HOME`, `TMPDIR` and the XDG directories in its private directories;
97
+ `VIRTUAL_ENV` for a Python check; Corepack's home where Corepack keeps it.
98
+
99
+ ## What REDUE claims
100
+
101
+ CURRENT means the last sandboxed run's inputs are unchanged:
102
+
103
+ - every file in the checkout, git-ignored files and git-ignored links to
104
+ files in it included, except `.git`, `node_modules` and `.venv` (observed
105
+ as installed files, by identity) and runner caches: the general recipe's,
106
+ plus `__pycache__`, `.mypy_cache`, `.ruff_cache` and `.pytest_cache`;
107
+ - the environment the run gets (without display variables such as `TERM`
108
+ and `COLUMNS`), and the places it may read;
109
+ - Node's and the package managers' executables, or the interpreter the
110
+ `.venv` was made from, by file identity;
111
+ - every directory on PATH, by identity: installing, removing or upgrading a
112
+ program in one makes the result STALE;
113
+ - `.git/info/exclude` and `.git/config`, by content: ruff and biome read the
114
+ ignore rules there.
115
+
116
+ ## Telling a refusal from a real failure
117
+
118
+ A run the sandbox refused something fails like any other. REDUE does not run
119
+ every failure twice to find out:
120
+
121
+ - `init` runs each proposed check twice, plainly and sandboxed, and proposes
122
+ it only when both give the same exit status and the same output, and a
123
+ failure shows no sign of a refusal. The output is compared without colors,
124
+ timings, the temporary and home directories, and lines about caches.
125
+ Otherwise `init` writes what it would have without this recipe and says why.
126
+ These are the only runs `init` makes.
127
+ - When a sandboxed run fails and its output has a line that looks like a
128
+ refusal ("Operation not permitted", "Permission denied", "Read-only file
129
+ system", a name lookup or a connection that failed), REDUE runs the check
130
+ again outside the sandbox. If that exits differently, the result is the
131
+ plain run's and UNVERIFIED: "the sandbox changed the result", with the
132
+ line. A failure without such a line is not run again.
133
+
134
+ ## The private home
135
+
136
+ The run's home directory is kept between runs, per check, so caches tools
137
+ build there (font caches, a type checker's cache) are not rebuilt every run.
138
+ It starts empty: your configuration files are not in it. What a run leaves
139
+ there can change a later run, and REDUE trusts it like a runner cache. Remove
140
+ it with `redue remove-state`.
141
+
142
+ ## Limits
143
+
144
+ - Inside Claude Code's own sandbox on macOS, `sandbox-exec` cannot start: the
145
+ run goes ahead unsandboxed and is UNVERIFIED ("the run was not sandboxed").
146
+ On Linux, Landlock works inside it.
147
+ - Windows is not supported. The check stays recording-only there.
148
+ - Linux before 6.7 (Landlock ABI below 4) cannot refuse TCP connections, so a
149
+ run there is UNVERIFIED. UDP is not refused on Linux.
150
+ - System files (time zones, certificates, `/etc`) are not inputs: a change to
151
+ them does not make a result STALE.
152
+ - A program that reads files in `.git` itself, without git (a commit hash for
153
+ a version string), reads something REDUE does not observe: a commit does
154
+ not make the result STALE.
155
+ - Runner caches in the checkout are trusted as the general recipe trusts them.
156
+ - On Linux, a run can still reach a daemon started outside it through a Unix
157
+ socket file (Landlock does not cover them), and what that daemon reads is
158
+ not seen. On macOS it cannot.
159
+ - On Linux, uv hard-links `.venv` files to its cache. The cache is
160
+ toolchain: a file rewritten through the cache, not through the `.venv`, is
161
+ noticed only when the observer next reads the `.venv` (when it starts).
162
+ - A `.venv` link that leads out of the checkout, other than the interpreter's,
163
+ keeps the check recording-only and names the link, and so does a `.venv`
164
+ that uses the system site-packages.
165
+ - A git-ignored link to a directory, or out of the checkout, is not observed,
166
+ and neither is a `node_modules` directory outside the installed ones (a
167
+ nested example project's).
168
+ - A script that changes files (`--fix`, `--write`) does not get this recipe:
169
+ `init` would run it twice.
@@ -18,11 +18,14 @@ build scripts. It prefers familiar script names (for typecheck: `typecheck`,
18
18
  (`typecheck:test`, `tsc:build`; not a watch script), then unique recognizable
19
19
  `tsc`, Vitest/Jest, ESLint, or Vite commands under other names. Multiple
20
20
  matches are reported as ambiguous rather than guessed. It never creates a new
21
- project script. Without `--recipe`, `init` tries the strict recipes below first,
22
- then the [general recipe](node-script.md) for a root typecheck or lint
23
- script: the script run through the package manager with every Node process
24
- recorded and the whole checkout as its input. `--recipe typescript-direct`,
25
- `tests-direct` or `node-script` selects one recipe alone. The direct TypeScript recipe supports
21
+ project script. Without `--recipe`, `init` tries the strict recipes below first, then,
22
+ for a root typecheck or lint script, the [sandboxed recipe](sandbox.md) on
23
+ macOS and Linux (the script run inside an OS sandbox, the whole checkout as
24
+ its input), then the [general recipe](node-script.md): the script run
25
+ through the package manager with every Node process recorded and the whole
26
+ checkout as its input. Python projects get the sandboxed recipe for mypy,
27
+ pyright, ty and ruff in their `.venv`. `--recipe typescript-direct`,
28
+ `tests-direct`, `node-script` or `sandbox` selects one recipe alone. The direct TypeScript recipe supports
26
29
  a typecheck script of `tsc --noEmit` runs in any argument order, chained runs,
27
30
  or no typecheck script at all (a proposed `tsc --noEmit`; see
28
31
  [script forms](direct-typescript.md#script-forms)), in a single-package npm installation using
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@redue/cli",
3
3
  "private": false,
4
- "version": "0.1.0-alpha.25",
4
+ "version": "0.1.0-alpha.26",
5
5
  "publishConfig": {
6
6
  "access": "public",
7
7
  "tag": "alpha"
@@ -22,10 +22,41 @@ const typecheck=check=>check?.kind==='typecheck'||check?.name==='typecheck';
22
22
  const tests=check=>check?.qualification==='npm-tests-direct-v1'&&Array.isArray(check.command)&&
23
23
  /^@test-runner:(?:vitest|jest)$/.test(check.command[1]||'');
24
24
  // A general recipe check: a root typecheck, test or lint script run through the package manager.
25
- const general=check=>check?.qualification==='node-script-v1'&&['typecheck','test','lint'].includes(check.kind)&&
26
- typeof check.script==='string';
27
- const eligibleCheck=check=>typecheck(check)||tests(check)||general(check);
28
- export const checkKind=check=>tests(check)?'test':general(check)?check.kind:'typecheck';
25
+ const general=check=>(check?.qualification==='node-script-v1'&&['typecheck','test','lint'].includes(check.kind)||
26
+ sandboxed(check))&&typeof check.script==='string';
27
+ // A sandboxed check: a root package script, or a tool from the project's .venv.
28
+ const sandboxed=check=>check?.qualification==='sandbox-v1'&&['typecheck','lint'].includes(check.kind);
29
+ const python=check=>sandboxed(check)&&Array.isArray(check.command)&&/^\.venv\/bin\/[A-Za-z0-9_.-]+$/.test(check.command[0]||'')&&
30
+ check.command.every(word=>typeof word==='string'&&safe.test(word));
31
+ const eligibleCheck=check=>typecheck(check)||tests(check)||general(check)||python(check);
32
+ export const checkKind=check=>tests(check)?'test':general(check)||python(check)?check.kind:'typecheck';
33
+ // The ways an agent runs a Python check's tool: through uv or Poetry, with
34
+ // `python -m`, from .venv/bin, or by name. A form that starts with `python`
35
+ // or the tool's name depends on PATH, so the hook takes it only when PATH
36
+ // finds that program in the project's .venv/bin first (pathForm).
37
+ const wrappers=['uv run ','poetry run '];
38
+ function pythonForms(check){
39
+ const [file,...args]=check.command,tool=file.split('/').at(-1),rest=args.length?' '+args.join(' '):'';
40
+ return [`.venv/bin/${tool}${rest}`,`./.venv/bin/${tool}${rest}`,`.venv/bin/python -m ${tool}${rest}`,
41
+ ...wrappers.flatMap(wrapper=>[`${wrapper}${tool}${rest}`,`${wrapper}python -m ${tool}${rest}`]),
42
+ `python -m ${tool}${rest}`,`python3 -m ${tool}${rest}`,`${tool}${rest}`];
43
+ }
44
+ // For a form that names a program PATH finds (`mypy .`, `python -m mypy .`),
45
+ // whether PATH finds it first in the project's .venv/bin.
46
+ export function pathForm(form,projectRoot,env=process.env){
47
+ const word=form.split(' ')[0];
48
+ if(word.includes('/')||wrappers.some(wrapper=>form.startsWith(wrapper)))return true;
49
+ if(!projectRoot)return false;
50
+ for(const dir of String(env.PATH??'').split(path.delimiter)){
51
+ if(!dir||!path.isAbsolute(dir))continue;
52
+ try{fs.accessSync(path.join(dir,word),fs.constants.X_OK);}catch{continue;}
53
+ try{return fs.realpathSync(dir)===fs.realpathSync(path.join(projectRoot,'.venv','bin'));}catch{return false;}
54
+ }
55
+ return false;
56
+ }
57
+ // The wrapper a matched form starts with, kept when the hook runs the check
58
+ // through REDUE: `uv run` and `poetry run` sync the environment first.
59
+ const wrapperOf=form=>wrappers.find(wrapper=>form.startsWith(wrapper))||'';
29
60
 
30
61
  export const defaultConfig='redue.config.json';
31
62
  // The `redue` prefix for a config beside the project root; null when the name
@@ -141,7 +172,8 @@ export function hookForms(config,configFile=defaultConfig,packageScripts=null){
141
172
  const forms=redue?[`${redue} run ${check.name}`]:[],local=[],dir=checkDir(check),key=groupOf(check),
142
173
  group=key?scripts.get(key):null,part=group?.length>1;
143
174
  const own=group?null:checkScript(check);
144
- if(dir==='.'){
175
+ if(dir==='.'&&python(check))forms.push(...pythonForms(check));
176
+ else if(dir==='.'){
145
177
  forms.push(...scriptForms(pm,own),...toolForms(pm,toolText(check,packageScripts)));
146
178
  if(!tests(check)&&Array.isArray(check.command)&&check.command.length&&
147
179
  check.command.every(v=>typeof v==='string'&&safe.test(v)&&!v.startsWith('@')))
@@ -258,11 +290,16 @@ function split(command){
258
290
  // {check, rest}, or {fanout, rest} for a turbo/nx/`pnpm -r` command or a root
259
291
  // script that runs one; null when nothing matches. `dir` is where the command
260
292
  // runs, relative to the project root.
261
- function matched(command,config,configFile,dir='.',scripts={}){
293
+ function matched(command,config,configFile,dir='.',scripts={},projectRoot=null){
262
294
  const parts=split(command);
263
295
  if(!parts)return null;
264
296
  const row=hookForms(config,configFile,dir==='.'?scripts:null).find(row=>dir==='.'?row.forms.includes(parts.base):
265
297
  row.dir===dir&&row.local.includes(parts.base));
298
+ // A Python check's form that depends on PATH matches only where PATH finds
299
+ // the project's .venv/bin.
300
+ const check=row&&config.checks.find(item=>item.name===row.check);
301
+ if(python(check)&&!parts.base.startsWith('redue'))
302
+ return pathForm(parts.base,projectRoot)?{check:row.check,rest:parts.rest,wrapper:wrapperOf(parts.base)}:null;
266
303
  // A root script that a general check runs is that check, even when the script fans out.
267
304
  if(dir==='.'&&row&&general(config.checks.find(check=>check.name===row.check)))return {check:row.check,rest:parts.rest};
268
305
  if(dir==='.'){
@@ -331,7 +368,7 @@ export function chainCommand(command,config,configFile,dir='.',scripts={},invoke
331
368
  const names=[];let plain=true;
332
369
  const text=parts.map(({separator,text})=>{
333
370
  const lead=text.match(/^\s*/)[0],trail=text.match(/\s*$/)[0],body=text.trim();
334
- const match=matched(body,config,configFile,dir,scripts),split_=split(body);
371
+ const match=matched(body,config,configFile,dir,scripts,projectRoot),split_=split(body);
335
372
  // A compiler script is its checks in the order of its runs.
336
373
  const checks=match?.check?[match.check]:match?.row&&projectRoot?scriptChecks(match.row,config,projectRoot):null;
337
374
  if(!checks?.length||match.rest!==null||!split_||!checks.every(name=>safe.test(name))){
@@ -339,22 +376,23 @@ export function chainCommand(command,config,configFile,dir='.',scripts={},invoke
339
376
  if(!plain)return null;
340
377
  names.push(...checks);
341
378
  // Grouped, so a separator before it applies to all its runs.
342
- const runs=checks.map(name=>`${redue} run ${name} --if-due`).join(' && ');
379
+ const runs=checks.map(name=>`${match.wrapper||''}${redue} run ${name} --if-due`).join(' && ');
343
380
  return `${separator}${lead}${checks.length>1?`{ ${runs}; }`:runs}${split_.suffix}${trail}`;
344
381
  });
345
382
  return names.length&&!text.includes(null)?{command:text.join(''),names}:null;
346
383
  }
347
- export function matchCheck(command,config,configFile,dir,scripts){
348
- const match=matched(command,config,configFile,dir,scripts);
384
+ export function matchCheck(command,config,configFile,dir,scripts,projectRoot=null){
385
+ const match=matched(command,config,configFile,dir,scripts,projectRoot);
349
386
  return match?.row?match.row.checks.join(' && '):match?.check||null;
350
387
  }
351
388
  // The same check (or checks, in order) through REDUE, keeping the output
352
389
  // suffix; null if it already is, or if more commands follow it.
353
- export function recordedCommand(command,name,configFile=defaultConfig,invoke='redue'){
390
+ // A `uv run` or `poetry run` wrapper stays in front, so the environment syncs first.
391
+ export function recordedCommand(command,name,configFile=defaultConfig,invoke='redue',wrapper=''){
354
392
  const names=Array.isArray(name)?name:[name];
355
393
  const parts=split(command),redue=redueFor(configFile)?.replace(/^redue/,()=>invoke);
356
394
  if(!parts||parts.rest!==null||!names.length||!names.every(value=>safe.test(value))||!redue)return null;
357
- const run=names.map(value=>`${redue} run ${value}`).join(' && ');
395
+ const run=names.map(value=>`${wrapper}${redue} run ${value}`).join(' && ');
358
396
  return parts.base===run?null:names.length>1&&parts.suffix?`(${run})${parts.suffix}`:run+parts.suffix;
359
397
  }
360
398
 
@@ -488,7 +526,7 @@ export async function claudeHook(input,{entry,timeout,invoke=null}){
488
526
  return cached?.observation?.healthy===true&&chained.names.every(name=>cached.checks?.some(row=>row?.name===name))?
489
527
  {hookSpecificOutput:{hookEventName:'PreToolUse',updatedInput:{...input.tool_input,command:entered.prefix+chained.command}}}:null;
490
528
  }
491
- const match=matched(entered.command,parsed,record.config,entered.dir,scripts);
529
+ const match=matched(entered.command,parsed,record.config,entered.dir,scripts,project.projectRoot);
492
530
  if(!match)return null;
493
531
  // A check followed by more than a simple chain runs untouched.
494
532
  if(match.rest!==null&&!match.fanout)return null;
@@ -530,7 +568,7 @@ export async function claudeHook(input,{entry,timeout,invoke=null}){
530
568
  // stays too: the rewrite returns "allow", which would also skip the prompt
531
569
  // for the rest of the chain.
532
570
  const rewritten=status?.schema===1&&status.observation?.healthy===true&&Array.isArray(status.checks)&&
533
- status.checks.some(item=>item?.name===name)?recordedCommand(entered.command,name,configPath,invoke??redueInvocation(entry)):null;
571
+ status.checks.some(item=>item?.name===name)?recordedCommand(entered.command,name,configPath,invoke??redueInvocation(entry),match.wrapper):null;
534
572
  // updatedInput replaces the whole tool input. Deny and ask rules still apply
535
573
  // to the rewritten command; "allow" skips only the prompt.
536
574
  return rewritten?{hookSpecificOutput:{hookEventName:'PreToolUse',permissionDecision:'allow',