ai2rules-harness 0.1.1 → 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/README.md CHANGED
@@ -3,18 +3,39 @@
3
3
  **Govern a local coding agent in one command.**
4
4
 
5
5
  ```bash
6
- npx ai2rules-harness init
6
+ npm install -g ai2rules-harness
7
+ harness init
7
8
  ```
8
9
 
9
10
  That writes a governance manifest, a `PreToolUse` shim, and the host settings entry
10
11
  into the current project. It is safe to run twice, it never replaces a manifest you
11
12
  have tuned, and turning it off is one file.
12
13
 
13
- > **Not on npm yet.** The prebuilt binaries this wrapper downloads **do** exist —
14
- > `v0.1.1`, four platforms, each with a published checksum — but the package
15
- > itself has not been pushed to the registry. Until it is:
16
- > `cargo install --git https://github.com/sv-pro/ai2rules cli-harness`, then
17
- > `harness init`. See `DECISIONS.md` D56.
14
+ > **Install it globally, not into a project.** `harness init` **refuses** to run
15
+ > when the binary lives inside the project it would govern — a local
16
+ > `node_modules` install puts the kernel in the agent's own writable root, where
17
+ > replacing one file turns governance off silently. `npx ai2rules-harness init`
18
+ > is fine: its cache is outside your project.
19
+
20
+ ## How it installs — and why there is no install script
21
+
22
+ The binary arrives as a **platform package** (`ai2rules-harness-linux-x64` and
23
+ friends), listed in `optionalDependencies`. npm resolves exactly one by `os`/`cpu`
24
+ and skips the rest.
25
+
26
+ That means installing this package runs **no install script, makes no network
27
+ request, executes no shell, and chmods nothing**. Earlier versions downloaded a
28
+ binary in a `postinstall` and verified a checksum fetched from the same host — which
29
+ proves the file arrived intact, not that it is the right file. Now the binary is
30
+ covered by the integrity hash npm writes into *your* lockfile, and the tarballs
31
+ carry [npm provenance](https://docs.npmjs.com/generating-provenance-statements)
32
+ signed against the workflow and commit that built them.
33
+
34
+ Practical upshots: installs are reproducible, work offline from a cache, and work
35
+ behind a proxy or a mirrored registry — none of which was true before.
36
+
37
+ If your platform has no prebuilt binary, nothing silently breaks: the CLI tells you
38
+ how to build one, or you point `AI2RULES_HARNESS` at an existing binary.
18
39
 
19
40
  ## What it does
20
41
 
@@ -54,10 +75,12 @@ looks like.
54
75
  Effective on the very next tool call, no restart:
55
76
 
56
77
  ```bash
57
- touch .claude/gate-off # this project
58
- touch ~/.claude/gate-off # everywhere
78
+ touch ~/.claude/ai2rules/off/<your-project> # this project
79
+ touch ~/.claude/gate-off # everywhere
59
80
  ```
60
81
 
82
+ `harness init` prints the exact per-project path. Both live **outside** the
83
+ governed project on purpose: a switch inside it is one the agent can throw itself.
61
84
  Delete the file to re-enable. The shim runs per call, so the toggle is immediate.
62
85
 
63
86
  ## Honest boundaries
package/bin/harness.js CHANGED
@@ -1,26 +1,58 @@
1
1
  #!/usr/bin/env node
2
- // Thin launcher: exec the real Rust binary, pass through argv, exit with its code.
2
+ // Launcher: find the prebuilt binary and exec it, passing argv through.
3
3
  //
4
- // No logic lives here. This file exists so `npx ai2rules-harness init` works, and
5
- // so a missing binary produces one clear sentence instead of a stack trace.
4
+ // There is no install script. The binary arrives as an `optionalDependencies`
5
+ // entry that npm resolves by `os`/`cpu` — exactly one matches, the rest are
6
+ // skipped — so installing this package performs **no network access, no shell
7
+ // execution, and no chmod**. That is not a score optimisation: it means the
8
+ // binary you run is covered by the integrity hash npm already wrote into your
9
+ // lockfile, rather than by a checksum this package fetched from the same host it
10
+ // fetched the binary from. The old design proved integrity; this one has
11
+ // provenance.
12
+ //
13
+ // Escape hatch for unsupported platforms and for development: set
14
+ // AI2RULES_HARNESS to an absolute path and it is used verbatim.
6
15
 
7
16
  const fs = require('fs');
8
17
  const path = require('path');
9
18
  const { spawnSync } = require('child_process');
10
19
 
11
- const vendored = path.join(
12
- __dirname,
13
- '..',
14
- 'vendor',
15
- process.platform === 'win32' ? 'harness.exe' : 'harness'
16
- );
17
- const bin = process.env.AI2RULES_HARNESS || vendored;
20
+ const PKG = {
21
+ 'linux-x64': 'ai2rules-harness-linux-x64',
22
+ 'darwin-x64': 'ai2rules-harness-darwin-x64',
23
+ 'darwin-arm64': 'ai2rules-harness-darwin-arm64',
24
+ 'win32-x64': 'ai2rules-harness-windows-x64', // `win32` in an unscoped
25
+ // package name is rejected by npm's spam filter; the platform key is unchanged
26
+ };
27
+
28
+ function resolveBinary() {
29
+ const override = process.env.AI2RULES_HARNESS;
30
+ if (override) return { bin: override, how: 'AI2RULES_HARNESS' };
31
+
32
+ const key = `${process.platform}-${process.arch}`;
33
+ const pkg = PKG[key];
34
+ if (!pkg) return { bin: null, why: `no prebuilt binary is published for ${key}` };
35
+
36
+ const exe = process.platform === 'win32' ? 'harness.exe' : 'harness';
37
+ try {
38
+ // Resolve through the package's own manifest so this works with npm,
39
+ // pnpm and yarn layouts alike rather than assuming node_modules is flat.
40
+ const manifest = require.resolve(`${pkg}/package.json`);
41
+ return { bin: path.join(path.dirname(manifest), exe), how: pkg };
42
+ } catch {
43
+ return {
44
+ bin: null,
45
+ why: `the optional dependency ${pkg} is not installed (it may have been skipped by --no-optional, or filtered by a lockfile from a different platform)`,
46
+ };
47
+ }
48
+ }
49
+
50
+ const { bin, why, how } = resolveBinary();
18
51
 
19
- if (!fs.existsSync(bin)) {
20
- console.error(`ai2rules-harness: no binary at ${bin}
52
+ if (!bin || !fs.existsSync(bin)) {
53
+ console.error(`ai2rules-harness: no usable binary — ${why || `${bin} is missing`}
21
54
 
22
- The postinstall step did not install one for ${process.platform}-${process.arch}.
23
- Build it, or point at an existing binary:
55
+ Build one, or point at an existing binary:
24
56
 
25
57
  cargo install --git https://github.com/sv-pro/ai2rules cli-harness
26
58
  export AI2RULES_HARNESS=/absolute/path/to/harness
@@ -30,7 +62,7 @@ Build it, or point at an existing binary:
30
62
 
31
63
  const res = spawnSync(bin, process.argv.slice(2), { stdio: 'inherit' });
32
64
  if (res.error) {
33
- console.error(`ai2rules-harness: cannot execute ${bin}: ${res.error.message}`);
65
+ console.error(`ai2rules-harness: cannot execute ${bin} (via ${how}): ${res.error.message}`);
34
66
  process.exit(126);
35
67
  }
36
68
  process.exit(res.status === null ? 1 : res.status);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai2rules-harness",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "Deterministic governance for local CLI coding agents. `npx ai2rules-harness init` governs a project in one command.",
5
5
  "license": "MIT OR Apache-2.0",
6
6
  "repository": {
@@ -22,17 +22,19 @@
22
22
  "bin": {
23
23
  "harness": "bin/harness.js"
24
24
  },
25
- "scripts": {
26
- "postinstall": "node install.js"
27
- },
28
25
  "files": [
29
26
  "bin/harness.js",
30
- "install.js",
31
27
  "README.md",
32
28
  "LICENSE-MIT",
33
29
  "LICENSE-APACHE"
34
30
  ],
35
31
  "engines": {
36
32
  "node": ">=18"
33
+ },
34
+ "optionalDependencies": {
35
+ "ai2rules-harness-darwin-arm64": "0.2.0",
36
+ "ai2rules-harness-darwin-x64": "0.2.0",
37
+ "ai2rules-harness-linux-x64": "0.2.0",
38
+ "ai2rules-harness-windows-x64": "0.2.0"
37
39
  }
38
40
  }
package/install.js DELETED
@@ -1,144 +0,0 @@
1
- #!/usr/bin/env node
2
- // postinstall — fetch the prebuilt `harness` for this platform.
3
- //
4
- // The asset names, targets and archive formats here mirror
5
- // `.github/workflows/release.yml` exactly. If that matrix changes, this map has
6
- // to change with it; `npm/verify-targets.js` fails the build when they drift.
7
- //
8
- // Deliberate properties, because this runs on other people's machines as part of
9
- // installing a *security* tool:
10
- //
11
- // * One file, one host, and the URL derives from this package's version —
12
- // never from anything in the user's project.
13
- // * The SHA-256 is verified against the digest published beside the asset. A
14
- // missing or mismatched checksum is a hard failure, not a warning.
15
- // * On any failure it exits 0 with instructions rather than breaking the
16
- // install of whatever depends on it. `bin/harness.js` reports the missing
17
- // binary if and when someone actually runs it.
18
- //
19
- // Offline / air-gapped: set AI2RULES_HARNESS to an existing binary and nothing
20
- // is downloaded at all.
21
-
22
- const fs = require('fs');
23
- const os = require('os');
24
- const path = require('path');
25
- const crypto = require('crypto');
26
- const { spawnSync } = require('child_process');
27
- const { pipeline } = require('stream/promises');
28
-
29
- const REPO = 'sv-pro/ai2rules';
30
- const VERSION = require('./package.json').version;
31
- const VENDOR = path.join(__dirname, 'vendor');
32
- const EXE = process.platform === 'win32' ? 'harness.exe' : 'harness';
33
-
34
- // npm `${platform}-${arch}` -> what release.yml actually publishes.
35
- // Linux is **musl** (statically linked, so it runs on any distro). There is no
36
- // aarch64-linux build in the matrix yet; that platform correctly falls through
37
- // to the build-from-source hint rather than downloading something wrong.
38
- const TARGETS = {
39
- 'linux-x64': { target: 'x86_64-unknown-linux-musl', archive: 'tar.gz' },
40
- 'darwin-arm64': { target: 'aarch64-apple-darwin', archive: 'tar.gz' },
41
- 'darwin-x64': { target: 'x86_64-apple-darwin', archive: 'tar.gz' },
42
- 'win32-x64': { target: 'x86_64-pc-windows-msvc', archive: 'zip' },
43
- };
44
-
45
- function hint(reason) {
46
- console.error(`
47
- ai2rules-harness: ${reason}
48
-
49
- No prebuilt binary was installed. You can still build one:
50
-
51
- cargo install --git https://github.com/${REPO} cli-harness
52
-
53
- then run \`harness init\` in a project. If you already have a binary:
54
-
55
- export AI2RULES_HARNESS=/absolute/path/to/harness
56
- `);
57
- }
58
-
59
- async function download(url, dest) {
60
- const res = await fetch(url, { redirect: 'follow' });
61
- if (!res.ok) throw new Error(`GET ${url} -> HTTP ${res.status}`);
62
- await fs.promises.mkdir(path.dirname(dest), { recursive: true });
63
- await pipeline(res.body, fs.createWriteStream(dest));
64
- }
65
-
66
- async function fetchText(url) {
67
- const res = await fetch(url, { redirect: 'follow' });
68
- if (!res.ok) throw new Error(`GET ${url} -> HTTP ${res.status}`);
69
- return (await res.text()).trim();
70
- }
71
-
72
- function sha256(file) {
73
- return new Promise((resolve, reject) => {
74
- const h = crypto.createHash('sha256');
75
- fs.createReadStream(file)
76
- .on('data', (d) => h.update(d))
77
- .on('end', () => resolve(h.digest('hex')))
78
- .on('error', reject);
79
- });
80
- }
81
-
82
- // `tar` extracts both .tar.gz and .zip: GNU/bsdtar on Unix, and bsdtar has
83
- // shipped as tar.exe on Windows 10+ since 1803. Avoids a dependency for a
84
- // package whose whole point is that it installs cleanly.
85
- function extract(archive, dir) {
86
- const res = spawnSync('tar', ['-xf', archive, '-C', dir], { stdio: 'pipe' });
87
- if (res.error) throw new Error(`cannot run tar: ${res.error.message}`);
88
- if (res.status !== 0) {
89
- throw new Error(`tar failed: ${res.stderr?.toString().trim() || `exit ${res.status}`}`);
90
- }
91
- }
92
-
93
- async function main() {
94
- const override = process.env.AI2RULES_HARNESS;
95
- if (override) {
96
- if (!fs.existsSync(override)) {
97
- hint(`AI2RULES_HARNESS points at ${override}, which does not exist.`);
98
- return;
99
- }
100
- console.log(`ai2rules-harness: using AI2RULES_HARNESS=${override}`);
101
- return;
102
- }
103
-
104
- const key = `${process.platform}-${process.arch}`;
105
- const spec = TARGETS[key];
106
- if (!spec) {
107
- hint(`no prebuilt binary for ${key} (${os.type()}).`);
108
- return;
109
- }
110
-
111
- const name = `harness-${spec.target}.${spec.archive}`;
112
- const url = `https://github.com/${REPO}/releases/download/v${VERSION}/${name}`;
113
- const tmp = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'ai2rules-'));
114
- const archive = path.join(tmp, name);
115
-
116
- try {
117
- await download(url, archive);
118
-
119
- const expected = (await fetchText(`${url}.sha256`)).split(/\s+/)[0];
120
- const actual = await sha256(archive);
121
- if (!expected || expected !== actual) {
122
- throw new Error(`checksum mismatch\n expected ${expected || '(none published)'}\n actual ${actual}`);
123
- }
124
-
125
- extract(archive, tmp);
126
- // The archive contains `harness-<target>/harness` — staged that way so it
127
- // has no nested target/.../release path.
128
- const unpacked = path.join(tmp, `harness-${spec.target}`, EXE);
129
- if (!fs.existsSync(unpacked)) throw new Error(`archive did not contain ${EXE}`);
130
-
131
- await fs.promises.mkdir(VENDOR, { recursive: true });
132
- await fs.promises.copyFile(unpacked, path.join(VENDOR, EXE));
133
- if (process.platform !== 'win32') await fs.promises.chmod(path.join(VENDOR, EXE), 0o755);
134
-
135
- console.log(`ai2rules-harness: installed ${spec.target} (sha256 ${actual.slice(0, 12)}…)`);
136
- } catch (err) {
137
- await fs.promises.rm(VENDOR, { recursive: true, force: true }).catch(() => {});
138
- hint(`could not install a prebuilt binary: ${err.message}`);
139
- } finally {
140
- await fs.promises.rm(tmp, { recursive: true, force: true }).catch(() => {});
141
- }
142
- }
143
-
144
- main().catch((err) => hint(String(err && err.message ? err.message : err)));