@cobusgreyling/loop-gate 1.0.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 +72 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +80 -0
- package/dist/gate.d.ts +45 -0
- package/dist/gate.js +99 -0
- package/package.json +59 -0
package/README.md
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# loop-gate
|
|
2
|
+
|
|
3
|
+
Mechanical enforcement of static safety policy — the code behind `docs/safety.md`'s Path Denylist and Auto-Merge Policy, and `LOOP.md`'s *"No auto-merge on main except trivial dependency patches"* / *"Denylist... without human review"* rules. Nothing evaluated a proposed change against that prose before; `loop-gate` does.
|
|
4
|
+
|
|
5
|
+
Deliberately has **no knowledge of run history**. Stagnation, repeated failures, and token/daily budgets already belong to [`loop-context`](../loop-context)'s circuit breaker — `loop-gate` only looks at *what* is being proposed (which paths, what action type), not *how the run has behaved so far*. Chain the two:
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
loop-context --check --ledger run.json ... || exit 2 # run-history based
|
|
9
|
+
loop-gate check --action auto-merge --paths a.ts,b.ts || exit 2 # policy based
|
|
10
|
+
do-the-merge
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Install & Run
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npx @cobusgreyling/loop-gate check --action auto-merge --paths docs/guide.md
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
**From this repo:**
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
cd tools/loop-gate
|
|
23
|
+
npm install
|
|
24
|
+
npm test
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Usage
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
loop-gate check --action <commit|merge|auto-merge> --paths <f1,f2,...> [--gate-file gate.yaml] [--json]
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
| Flag | Default | Meaning |
|
|
34
|
+
|------|---------|---------|
|
|
35
|
+
| `--action <commit\|merge\|auto-merge>` | required | What the loop is about to do |
|
|
36
|
+
| `--paths <f1,f2,...>` | required | Comma-separated changed file paths |
|
|
37
|
+
| `--gate-file <path>` | `gate.yaml` (cwd) | Policy file to evaluate against |
|
|
38
|
+
| `--json` | off | Machine-readable decision output |
|
|
39
|
+
|
|
40
|
+
Exit codes: `0` allowed · `2` escalate · `1` error (bad flags, missing/invalid config).
|
|
41
|
+
|
|
42
|
+
## Policy file
|
|
43
|
+
|
|
44
|
+
`gate.yaml` is the machine-readable twin of `docs/safety.md` — see [`templates/gate.yaml.template`](../../templates/gate.yaml.template) to scaffold your own, or this repo's own dogfood [`gate.yaml`](../../gate.yaml):
|
|
45
|
+
|
|
46
|
+
```yaml
|
|
47
|
+
version: 1
|
|
48
|
+
denylist:
|
|
49
|
+
- ".env"
|
|
50
|
+
- "**/secrets/**"
|
|
51
|
+
- "auth/**"
|
|
52
|
+
maxFiles: 10
|
|
53
|
+
autoMergeAllowlist:
|
|
54
|
+
- "docs/**"
|
|
55
|
+
- "**/*.md"
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Checked in order (first match wins, same "most specific trigger first" convention `loop-context`'s circuit breaker uses):
|
|
59
|
+
|
|
60
|
+
1. **`denylist`** — any changed path matching a glob here escalates, regardless of `--action`.
|
|
61
|
+
2. **`maxFiles`** — more changed paths than this escalates (matches `docs/safety.md`'s "Changes touching >N files" human gate).
|
|
62
|
+
3. **`autoMergeAllowlist`** — only checked when `--action auto-merge`; every changed path must match one of these globs, or it escalates.
|
|
63
|
+
|
|
64
|
+
Glob matching is via [`minimatch`](https://www.npmjs.com/package/minimatch) (the same semantics `.gitignore`-style globs use — `**` matches across path segments).
|
|
65
|
+
|
|
66
|
+
## What this does not do
|
|
67
|
+
|
|
68
|
+
- Does not read a run ledger, and never will — that keeps it decoupled from `loop-context` at the source level, the same way `loop-worktree` and `loop-context` stay independent while still being paired by convention in a control script.
|
|
69
|
+
- Does not produce its own escalation summary format — fold its JSON decision into whatever your control script already assembles from `loop-context --inject` when escalating to a human.
|
|
70
|
+
- Does not enforce anything by itself — like `loop-worktree`'s locks, this is advisory: a control script that skips calling `loop-gate` is not physically blocked. The mechanism is only as good as the scripts that call it.
|
|
71
|
+
|
|
72
|
+
See [docs/safety.md](../../docs/safety.md) for the policy this codifies, and [docs/primitives.md](../../docs/primitives.md) for where safety gates fit in the Five Building Blocks + Memory model.
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { checkGate, loadGateConfig, assertValidAction, } from './gate.js';
|
|
3
|
+
function parseFlags(argv) {
|
|
4
|
+
const flags = { help: false, gateFile: 'gate.yaml', json: false };
|
|
5
|
+
for (let i = 0; i < argv.length; i++) {
|
|
6
|
+
const a = argv[i];
|
|
7
|
+
if (a === '--help' || a === '-h')
|
|
8
|
+
flags.help = true;
|
|
9
|
+
else if (a === '--action')
|
|
10
|
+
flags.action = argv[++i];
|
|
11
|
+
else if (a === '--paths')
|
|
12
|
+
flags.paths = argv[++i];
|
|
13
|
+
else if (a === '--gate-file')
|
|
14
|
+
flags.gateFile = argv[++i];
|
|
15
|
+
else if (a === '--json')
|
|
16
|
+
flags.json = true;
|
|
17
|
+
}
|
|
18
|
+
return flags;
|
|
19
|
+
}
|
|
20
|
+
const HELP = `loop-gate — mechanical enforcement of static safety policy
|
|
21
|
+
|
|
22
|
+
Evaluates a proposed change (action type + changed paths) against
|
|
23
|
+
gate.yaml's denylist, max-files, and auto-merge allowlist. No knowledge of
|
|
24
|
+
run history — pair with loop-context --check for stagnation/budget triggers.
|
|
25
|
+
|
|
26
|
+
Usage:
|
|
27
|
+
loop-gate check --action <commit|merge|auto-merge> --paths <f1,f2,...> [options]
|
|
28
|
+
|
|
29
|
+
Options:
|
|
30
|
+
--action <commit|merge|auto-merge> What the loop is about to do
|
|
31
|
+
--paths <f1,f2,...> Comma-separated changed file paths
|
|
32
|
+
--gate-file <path> Policy file (default: gate.yaml)
|
|
33
|
+
--json Machine-readable output
|
|
34
|
+
-h, --help This help
|
|
35
|
+
|
|
36
|
+
Exit codes: 0 allowed · 2 escalate · 1 error
|
|
37
|
+
|
|
38
|
+
Example:
|
|
39
|
+
loop-gate check --action auto-merge --paths $(git diff --name-only | tr '\\n' ',')
|
|
40
|
+
`;
|
|
41
|
+
async function main() {
|
|
42
|
+
const argv = process.argv.slice(2);
|
|
43
|
+
const command = argv[0];
|
|
44
|
+
if (!command || command === '--help' || command === '-h') {
|
|
45
|
+
console.log(HELP);
|
|
46
|
+
return command ? 0 : 1;
|
|
47
|
+
}
|
|
48
|
+
if (command !== 'check') {
|
|
49
|
+
console.error(`Unknown command "${command}".\n\n${HELP}`);
|
|
50
|
+
return 1;
|
|
51
|
+
}
|
|
52
|
+
const flags = parseFlags(argv.slice(1));
|
|
53
|
+
if (flags.help) {
|
|
54
|
+
console.log(HELP);
|
|
55
|
+
return 0;
|
|
56
|
+
}
|
|
57
|
+
if (!flags.action || !flags.paths) {
|
|
58
|
+
throw new Error('check requires --action and --paths.');
|
|
59
|
+
}
|
|
60
|
+
assertValidAction(flags.action);
|
|
61
|
+
const config = await loadGateConfig(flags.gateFile);
|
|
62
|
+
const paths = flags.paths.split(',').map((p) => p.trim()).filter(Boolean);
|
|
63
|
+
if (paths.length === 0) {
|
|
64
|
+
throw new Error('--paths must contain at least one non-empty path.');
|
|
65
|
+
}
|
|
66
|
+
const decision = checkGate({ config, action: flags.action, paths });
|
|
67
|
+
if (flags.json) {
|
|
68
|
+
console.log(JSON.stringify(decision, null, 2));
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
console.log(`${decision.allowed ? 'ALLOWED' : 'ESCALATE'} [${decision.trigger}] — ${decision.reason}`);
|
|
72
|
+
}
|
|
73
|
+
return decision.allowed ? 0 : 2;
|
|
74
|
+
}
|
|
75
|
+
main()
|
|
76
|
+
.then((code) => process.exit(code))
|
|
77
|
+
.catch((err) => {
|
|
78
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
79
|
+
process.exit(1);
|
|
80
|
+
});
|
package/dist/gate.d.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mechanical enforcement of static policy (path denylist, auto-merge
|
|
3
|
+
* allowlist, max changed files) — the machine-readable twin of
|
|
4
|
+
* docs/safety.md's prose. Deliberately has no knowledge of run history
|
|
5
|
+
* (stagnation, repeat failures): that already belongs to loop-context's
|
|
6
|
+
* circuit breaker and must not be duplicated here.
|
|
7
|
+
*/
|
|
8
|
+
export type Action = 'commit' | 'merge' | 'auto-merge';
|
|
9
|
+
export declare const VALID_ACTIONS: Action[];
|
|
10
|
+
export declare function assertValidAction(action: string): asserts action is Action;
|
|
11
|
+
export interface GateConfig {
|
|
12
|
+
version: 1;
|
|
13
|
+
/** Glob patterns that must never be touched without human approval. */
|
|
14
|
+
denylist: string[];
|
|
15
|
+
/** Escalate when more than this many paths are in the proposed change. */
|
|
16
|
+
maxFiles?: number;
|
|
17
|
+
/** For --action auto-merge: every path must match one of these globs. */
|
|
18
|
+
autoMergeAllowlist?: string[];
|
|
19
|
+
}
|
|
20
|
+
export declare function loadGateConfig(file: string): Promise<GateConfig>;
|
|
21
|
+
export type GateTrigger = 'ok' | 'denylist' | 'file-count' | 'not-allowlisted';
|
|
22
|
+
export interface GateDecision {
|
|
23
|
+
allowed: boolean;
|
|
24
|
+
trigger: GateTrigger;
|
|
25
|
+
reason: string;
|
|
26
|
+
/** Paths responsible for the trigger (empty when trigger is 'ok'). */
|
|
27
|
+
matchedPaths: string[];
|
|
28
|
+
}
|
|
29
|
+
export interface CheckInput {
|
|
30
|
+
config: GateConfig;
|
|
31
|
+
action: Action;
|
|
32
|
+
paths: string[];
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Evaluate a proposed change against static policy. Checks the cheapest and
|
|
36
|
+
* most severe condition first (denylist), then file-count, then the
|
|
37
|
+
* auto-merge allowlist — mirroring loop-context's checkCircuitBreaker
|
|
38
|
+
* "most specific trigger first" ordering.
|
|
39
|
+
*
|
|
40
|
+
* All glob matching uses minimatch's `dot: true` — without it, `*` never
|
|
41
|
+
* matches a path segment starting with `.`, so denylist entries like
|
|
42
|
+
* `**\/secrets/**` or `**\/*_key*` would silently miss `.secrets/prod.json`
|
|
43
|
+
* or `.aws_key`, exactly the paths a denylist most needs to catch.
|
|
44
|
+
*/
|
|
45
|
+
export declare function checkGate(input: CheckInput): GateDecision;
|
package/dist/gate.js
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mechanical enforcement of static policy (path denylist, auto-merge
|
|
3
|
+
* allowlist, max changed files) — the machine-readable twin of
|
|
4
|
+
* docs/safety.md's prose. Deliberately has no knowledge of run history
|
|
5
|
+
* (stagnation, repeat failures): that already belongs to loop-context's
|
|
6
|
+
* circuit breaker and must not be duplicated here.
|
|
7
|
+
*/
|
|
8
|
+
import { readFile } from 'node:fs/promises';
|
|
9
|
+
import { parse } from 'yaml';
|
|
10
|
+
import { minimatch } from 'minimatch';
|
|
11
|
+
export const VALID_ACTIONS = ['commit', 'merge', 'auto-merge'];
|
|
12
|
+
export function assertValidAction(action) {
|
|
13
|
+
if (!VALID_ACTIONS.includes(action)) {
|
|
14
|
+
throw new Error(`Invalid --action: ${action}. Use one of: ${VALID_ACTIONS.join(', ')}.`);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
function isStringArray(value) {
|
|
18
|
+
return Array.isArray(value) && value.every((v) => typeof v === 'string');
|
|
19
|
+
}
|
|
20
|
+
export async function loadGateConfig(file) {
|
|
21
|
+
let raw;
|
|
22
|
+
try {
|
|
23
|
+
raw = await readFile(file, 'utf8');
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
throw new Error(`Gate config not found: ${file}. Create one (see templates/gate.yaml.template) or pass --gate-file <path>.`);
|
|
27
|
+
}
|
|
28
|
+
let parsed;
|
|
29
|
+
try {
|
|
30
|
+
parsed = parse(raw);
|
|
31
|
+
}
|
|
32
|
+
catch (err) {
|
|
33
|
+
throw new Error(`Invalid YAML in ${file}: ${err.message}`);
|
|
34
|
+
}
|
|
35
|
+
const config = parsed;
|
|
36
|
+
const invalid = (detail) => new Error(`Invalid gate config at ${file}: ${detail}`);
|
|
37
|
+
if (!config || config.version !== 1) {
|
|
38
|
+
throw invalid('expected { version: 1, denylist: string[], ... }.');
|
|
39
|
+
}
|
|
40
|
+
if (!isStringArray(config.denylist)) {
|
|
41
|
+
throw invalid('"denylist" must be an array of strings.');
|
|
42
|
+
}
|
|
43
|
+
if (config.maxFiles !== undefined && (typeof config.maxFiles !== 'number' || !Number.isFinite(config.maxFiles))) {
|
|
44
|
+
throw invalid('"maxFiles" must be a number.');
|
|
45
|
+
}
|
|
46
|
+
if (config.autoMergeAllowlist !== undefined && !isStringArray(config.autoMergeAllowlist)) {
|
|
47
|
+
throw invalid('"autoMergeAllowlist" must be an array of strings.');
|
|
48
|
+
}
|
|
49
|
+
return config;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Evaluate a proposed change against static policy. Checks the cheapest and
|
|
53
|
+
* most severe condition first (denylist), then file-count, then the
|
|
54
|
+
* auto-merge allowlist — mirroring loop-context's checkCircuitBreaker
|
|
55
|
+
* "most specific trigger first" ordering.
|
|
56
|
+
*
|
|
57
|
+
* All glob matching uses minimatch's `dot: true` — without it, `*` never
|
|
58
|
+
* matches a path segment starting with `.`, so denylist entries like
|
|
59
|
+
* `**\/secrets/**` or `**\/*_key*` would silently miss `.secrets/prod.json`
|
|
60
|
+
* or `.aws_key`, exactly the paths a denylist most needs to catch.
|
|
61
|
+
*/
|
|
62
|
+
export function checkGate(input) {
|
|
63
|
+
const { config, action, paths } = input;
|
|
64
|
+
const denylistHits = paths.filter((p) => config.denylist.some((glob) => minimatch(p, glob, { dot: true })));
|
|
65
|
+
if (denylistHits.length > 0) {
|
|
66
|
+
return {
|
|
67
|
+
allowed: false,
|
|
68
|
+
trigger: 'denylist',
|
|
69
|
+
reason: `${denylistHits.length} path(s) match the denylist: ${denylistHits.join(', ')}. Escalating for human review.`,
|
|
70
|
+
matchedPaths: denylistHits,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
if (config.maxFiles !== undefined && paths.length > config.maxFiles) {
|
|
74
|
+
return {
|
|
75
|
+
allowed: false,
|
|
76
|
+
trigger: 'file-count',
|
|
77
|
+
reason: `${paths.length} changed file(s) exceeds the max-files threshold (${config.maxFiles}). Escalating for human review.`,
|
|
78
|
+
matchedPaths: paths,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
if (action === 'auto-merge') {
|
|
82
|
+
const allowlist = config.autoMergeAllowlist ?? [];
|
|
83
|
+
const notAllowlisted = paths.filter((p) => !allowlist.some((glob) => minimatch(p, glob, { dot: true })));
|
|
84
|
+
if (notAllowlisted.length > 0) {
|
|
85
|
+
return {
|
|
86
|
+
allowed: false,
|
|
87
|
+
trigger: 'not-allowlisted',
|
|
88
|
+
reason: `${notAllowlisted.length} path(s) are not on the auto-merge allowlist: ${notAllowlisted.join(', ')}. Escalating for human review.`,
|
|
89
|
+
matchedPaths: notAllowlisted,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return {
|
|
94
|
+
allowed: true,
|
|
95
|
+
trigger: 'ok',
|
|
96
|
+
reason: 'Within policy — cleared to proceed.',
|
|
97
|
+
matchedPaths: [],
|
|
98
|
+
};
|
|
99
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@cobusgreyling/loop-gate",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Mechanical enforcement of LOOP.md/docs/safety.md's path denylist and auto-merge allowlist — evaluate a proposed change against static policy and block or allow it.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/gate.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./dist/gate.js"
|
|
9
|
+
},
|
|
10
|
+
"bin": {
|
|
11
|
+
"loop-gate": "dist/cli.js"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"dist",
|
|
15
|
+
"README.md"
|
|
16
|
+
],
|
|
17
|
+
"scripts": {
|
|
18
|
+
"build": "tsc",
|
|
19
|
+
"test": "npm run build && node --test test/*.test.mjs",
|
|
20
|
+
"prepublishOnly": "npm test",
|
|
21
|
+
"start": "node dist/cli.js"
|
|
22
|
+
},
|
|
23
|
+
"engines": {
|
|
24
|
+
"node": ">=18"
|
|
25
|
+
},
|
|
26
|
+
"keywords": [
|
|
27
|
+
"loop-engineering",
|
|
28
|
+
"ai-agents",
|
|
29
|
+
"coding-agents",
|
|
30
|
+
"safety",
|
|
31
|
+
"policy",
|
|
32
|
+
"auto-merge",
|
|
33
|
+
"claude-code",
|
|
34
|
+
"grok",
|
|
35
|
+
"devtools"
|
|
36
|
+
],
|
|
37
|
+
"author": "Cobus Greyling",
|
|
38
|
+
"license": "MIT",
|
|
39
|
+
"repository": {
|
|
40
|
+
"type": "git",
|
|
41
|
+
"url": "git+https://github.com/cobusgreyling/loop-engineering.git",
|
|
42
|
+
"directory": "tools/loop-gate"
|
|
43
|
+
},
|
|
44
|
+
"homepage": "https://cobusgreyling.github.io/loop-engineering/",
|
|
45
|
+
"bugs": {
|
|
46
|
+
"url": "https://github.com/cobusgreyling/loop-engineering/issues"
|
|
47
|
+
},
|
|
48
|
+
"publishConfig": {
|
|
49
|
+
"access": "public"
|
|
50
|
+
},
|
|
51
|
+
"dependencies": {
|
|
52
|
+
"minimatch": "^10.0.0",
|
|
53
|
+
"yaml": "^2.8.0"
|
|
54
|
+
},
|
|
55
|
+
"devDependencies": {
|
|
56
|
+
"@types/node": "^20.0.0",
|
|
57
|
+
"typescript": "^5.0.0"
|
|
58
|
+
}
|
|
59
|
+
}
|