Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 7 additions & 7 deletions docs/safety.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,18 @@ Loops amplify judgment — good and bad. These guardrails are minimum bar for pr
The loop must **never** auto-edit these without human approval:

```
.env
.env.*
**/.env
**/.env.*
**/secrets/**
**/credentials/**
**/*_key*
**/*_secret*
.terraform/**
k8s/production/**
**/.terraform/**
**/k8s/production/**
**/migrations/** # unless explicit migration loop
auth/**
payments/**
billing/**
**/auth/**
**/payments/**
**/billing/**
```

Encode in `minimal-fix` and implementer skills:
Expand Down
14 changes: 7 additions & 7 deletions gate.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,18 @@
version: 1

denylist:
- ".env"
- ".env.*"
- "**/.env"
- "**/.env.*"
- "**/secrets/**"
- "**/credentials/**"
- "**/*_key*"
- "**/*_secret*"
- ".terraform/**"
- "k8s/production/**"
- "**/.terraform/**"
- "**/k8s/production/**"
- "**/migrations/**"
- "auth/**"
- "payments/**"
- "billing/**"
- "**/auth/**"
- "**/payments/**"
- "**/billing/**"

maxFiles: 10

Expand Down
14 changes: 7 additions & 7 deletions templates/gate.yaml.template
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,18 @@
version: 1

denylist:
- ".env"
- ".env.*"
- "**/.env"
- "**/.env.*"
- "**/secrets/**"
- "**/credentials/**"
- "**/*_key*"
- "**/*_secret*"
- ".terraform/**"
- "k8s/production/**"
- "**/.terraform/**"
- "**/k8s/production/**"
- "**/migrations/**"
- "auth/**"
- "payments/**"
- "billing/**"
- "**/auth/**"
- "**/payments/**"
- "**/billing/**"

# Escalate instead of auto-merging when a change touches more than this many files.
maxFiles: 10
Expand Down
4 changes: 2 additions & 2 deletions tools/loop-audit/dist/autofixer.js
Original file line number Diff line number Diff line change
Expand Up @@ -152,8 +152,8 @@ const GATE_YAML_TEMPLATE = `# Machine-readable twin of docs/safety.md, enforced
version: 1

denylist:
- ".env"
- ".env.*"
- "**/.env"
- "**/.env.*"
- "**/secrets/**"
- "**/credentials/**"
- "**/*_key*"
Expand Down
4 changes: 2 additions & 2 deletions tools/loop-audit/src/autofixer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,8 +161,8 @@ const GATE_YAML_TEMPLATE = `# Machine-readable twin of docs/safety.md, enforced
version: 1

denylist:
- ".env"
- ".env.*"
- "**/.env"
- "**/.env.*"
- "**/secrets/**"
- "**/credentials/**"
- "**/*_key*"
Expand Down
15 changes: 15 additions & 0 deletions tools/loop-audit/test/autofixer.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,21 @@ test('autoFixProject writes a gate.yaml that loop-gate can actually load', async
assert.match(written, /version:\s*1/);
assert.match(written, /denylist:/);
assert.doesNotMatch(written, /^gates:/m);

// A bare pattern like ".env" only matches a path exactly equal to
// ".env" under minimatch -- it silently misses "services/api/.env".
// Every denylist entry here must be **/-anchored (or already contain
// its own ** segment) so it also catches the nested case, which is
// what "never auto-edit these" in docs/safety.md actually means.
const denylistBlock = written.match(/denylist:\n((?:\s+-\s+.+\n)+)/)?.[1] ?? '';
const entries = [...denylistBlock.matchAll(/-\s+"([^"]+)"/g)].map((m) => m[1]);
assert.ok(entries.length > 0, 'expected at least one denylist entry to check');
for (const entry of entries) {
assert.ok(
entry.startsWith('**/') || entry.includes('**'),
`denylist entry "${entry}" is not anchored and would miss a nested match`,
);
}
} finally {
await rm(root, { recursive: true, force: true }).catch(() => {});
}
Expand Down
27 changes: 27 additions & 0 deletions tools/loop-gate/test/gate.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,13 @@ import assert from 'node:assert/strict';
import { mkdtemp, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

import { checkGate, loadGateConfig, assertValidAction } from '../dist/gate.js';

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const REPO_GATE_YAML = path.resolve(__dirname, '../../../gate.yaml');

const baseConfig = {
version: 1,
denylist: ['.env', '**/secrets/**', 'auth/**'],
Expand Down Expand Up @@ -143,3 +147,26 @@ test('loadGateConfig rejects the wrong version', async () => {
const file = await freshGateFile('version: 2\ndenylist: []\n');
await assert.rejects(() => loadGateConfig(file), /Invalid gate config/);
});

// Dogfoods the actual policy this repo ships at gate.yaml, not just the
// matching mechanism -- a bare (unanchored) pattern like ".env" or "auth/**"
// only matches that exact root-level path and silently misses the same
// file/dir nested anywhere else, which a schema-validity check alone would
// never catch.
test("this repo's own gate.yaml denylist catches sensitive paths nested in a subdirectory, not just at repo root", async () => {
const config = await loadGateConfig(REPO_GATE_YAML);
const nestedSensitivePaths = [
'services/api/.env',
'services/api/.env.production',
'infra/.terraform/state.tfstate',
'apps/backend/k8s/production/deploy.yaml',
'apps/backend/auth/session.ts',
'apps/checkout/payments/charge.ts',
'apps/checkout/billing/invoice.ts',
];
for (const p of nestedSensitivePaths) {
const decision = checkGate({ config, action: 'commit', paths: [p] });
assert.equal(decision.allowed, false, `${p} should be denylisted, got: ${decision.reason}`);
assert.equal(decision.trigger, 'denylist');
}
});
14 changes: 7 additions & 7 deletions tools/loop-sync/dist/sync.js
Original file line number Diff line number Diff line change
Expand Up @@ -162,18 +162,18 @@ Run log: (timestamp) | findings | actions | escalations
version: 1

denylist:
- ".env"
- ".env.*"
- "**/.env"
- "**/.env.*"
- "**/secrets/**"
- "**/credentials/**"
- "**/*_key*"
- "**/*_secret*"
- ".terraform/**"
- "k8s/production/**"
- "**/.terraform/**"
- "**/k8s/production/**"
- "**/migrations/**"
- "auth/**"
- "payments/**"
- "billing/**"
- "**/auth/**"
- "**/payments/**"
- "**/billing/**"

# Escalate instead of auto-merging when a change touches more than this many files.
maxFiles: 10
Expand Down
14 changes: 7 additions & 7 deletions tools/loop-sync/src/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,18 +210,18 @@ Run log: (timestamp) | findings | actions | escalations
version: 1

denylist:
- ".env"
- ".env.*"
- "**/.env"
- "**/.env.*"
- "**/secrets/**"
- "**/credentials/**"
- "**/*_key*"
- "**/*_secret*"
- ".terraform/**"
- "k8s/production/**"
- "**/.terraform/**"
- "**/k8s/production/**"
- "**/migrations/**"
- "auth/**"
- "payments/**"
- "billing/**"
- "**/auth/**"
- "**/payments/**"
- "**/billing/**"

# Escalate instead of auto-merging when a change touches more than this many files.
maxFiles: 10
Expand Down
16 changes: 16 additions & 0 deletions tools/loop-sync/test/sync.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,22 @@ describe('runSync auto-fix', () => {
// loop-run-log.md must keep the marker append-run-log.mjs depends on.
const runLog = await readFile(path.join(fixDir, 'loop-run-log.md'), 'utf8');
assert.match(runLog, /<!-- Loop appends below this line -->/);

// A bare pattern like ".env" only matches a path exactly equal to
// ".env" under minimatch (tools/loop-gate's matcher) -- it silently
// misses "services/api/.env". Every denylist entry must be
// **/-anchored (or already contain its own ** segment) so the
// scaffolded policy actually catches a nested match too.
const gateYaml = await readFile(path.join(fixDir, 'gate.yaml'), 'utf8');
const denylistBlock = gateYaml.match(/denylist:\n((?:\s+-\s+.+\n)+)/)?.[1] ?? '';
const entries = [...denylistBlock.matchAll(/-\s+"([^"]+)"/g)].map((m) => m[1]);
assert.ok(entries.length > 0, 'expected at least one denylist entry to check');
for (const entry of entries) {
assert.ok(
entry.startsWith('**/') || entry.includes('**'),
`denylist entry "${entry}" is not anchored and would miss a nested match`,
);
}
});

test('does not fabricate LOOP.md or AGENTS.md -- still reported as missing', async () => {
Expand Down