@natjswenson/shipflow 0.2.3 → 0.2.5
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/SKILL.md +6 -3
- package/bin/shipflow.js +26 -2
- package/lib/apply.mjs +7 -2
- package/lib/detect.mjs +18 -9
- package/lib/gh.mjs +36 -2
- package/package.json +26 -7
- package/skill-invariants.json +10 -0
package/SKILL.md
CHANGED
|
@@ -66,11 +66,11 @@ user; the CLI is the only thing that *does*.
|
|
|
66
66
|
npx -y @natjswenson/shipflow apply --repo <path> --dry-run
|
|
67
67
|
```
|
|
68
68
|
|
|
69
|
-
9. **Apply for real**, passing the `stateHash` from step 7's plan output as `--expect-state-hash` — this is the TOCTOU guard: if repo state drifted between the plan you showed the user and this call, `apply` refuses to mutate anything and tells you to re-plan.
|
|
69
|
+
9. **Apply for real**, passing the `stateHash` from step 7's plan output as `--expect-state-hash` — this is the TOCTOU guard: if repo state drifted between the plan you showed the user and this call, `apply` refuses to mutate anything and tells you to re-plan. **`--expect-state-hash` is mandatory for a real (non-dry-run) apply** — omitting it is a hard CLI refusal, not a silent skip of the check; the only way around it is the explicitly-named `--skip-hash-check` escape hatch, which you should never reach for as a matter of course.
|
|
70
70
|
```
|
|
71
71
|
npx -y @natjswenson/shipflow apply --repo <path> --expect-state-hash <hash-from-step-7>
|
|
72
72
|
```
|
|
73
|
-
If a `handEditDetected` entry was confirmed for override in step 7, pass `--force <entry-id>` (repeatable — one flag per confirmed entry id, never a blanket override).
|
|
73
|
+
If a `handEditDetected` entry was confirmed for override in step 7, pass `--force <entry-id>` (repeatable — one flag per confirmed entry id, never a blanket override) **and** `--force-reason "<short justification>"` — the CLI refuses any `--force` without an accompanying reason, and that reason is echoed back in the apply result for auditability. Write a real justification tied to the user's actual confirmation (e.g. `--force-reason "user confirmed hand-edit override for the branch-rename migration on 2026-07-15"`), never a placeholder string.
|
|
74
74
|
|
|
75
75
|
10. **Report the result.** Read `applied`/`skipped`/`errors` from the response. A `skipped` entry can be a deliberate refusal (empty checks, hand-edit) or an environment limitation shipflow can't do anything about (e.g. a deletion-ruleset skipped because the repo is private and not on a paid GitHub tier) — read each `reason` and relay it plainly rather than treating every `skipped` entry the same. If `renderedTemplateHashes` is non-empty, update `.github/shipflow.json`'s `renderedTemplateHashes` field with those values and tell the user to commit the config change *and* the rendered workflow file **together, in the same commit** — a split commit is exactly what causes a false `handEditDetected` on a clean checkout later.
|
|
76
76
|
|
|
@@ -104,11 +104,14 @@ This is a **separate, later invocation** from the one that ran the promotion's `
|
|
|
104
104
|
|
|
105
105
|
## Error handling
|
|
106
106
|
|
|
107
|
-
- **Empty `requiredChecks`:** `apply` refuses to wire up auto-merge with zero required checks. Don't work around this by suggesting `--force allow-no-checks` unless the user has explicitly and knowingly accepted an unprotected merge — surface the refusal message plainly first.
|
|
107
|
+
- **Empty `requiredChecks`:** `apply` refuses to wire up auto-merge with zero required checks. Don't work around this by suggesting `--force allow-no-checks` (plus the now-mandatory `--force-reason`) unless the user has explicitly and knowingly accepted an unprotected merge — surface the refusal message plainly first.
|
|
108
108
|
- **`handEditDetected`:** a template file's on-disk content doesn't match what shipflow last rendered *or* what it would freshly render — someone hand-edited it. Never silently pass `--force` for this; always show the user what changed and get explicit confirmation per entry.
|
|
109
109
|
- **TOCTOU abort:** if `apply` returns a `toctou` error, repo state changed between plan and apply — re-run the plan step, don't retry the same `--expect-state-hash`.
|
|
110
110
|
- **`gh auth` failures:** surface these immediately; branch protection and rulesets need repo-admin scope. Don't proceed partway through a plan on missing auth.
|
|
111
111
|
- **`release.releaseCredential` left as (or defaulted to) `GITHUB_TOKEN`:** auto-merge and the required-check gate still work, but `label-release-pending` will silently never run — a `GITHUB_TOKEN`-attributed auto-merge's `pull_request: closed` event never triggers it, so no promotion will ever surface via `shipflow releases`. This fails silently, not loudly — there's no error to catch it — so it must be caught at setup time (step 5) rather than discovered later. If a user reports "releases never show up," check this first.
|
|
112
|
+
- **`--expect-state-hash is required` refusal:** a real apply was attempted with neither `--expect-state-hash` nor `--skip-hash-check`. Go back and get (or re-fetch via `plan`) the hash — don't reach for `--skip-hash-check` just to make the error go away; that flag exists for a deliberate, documented exception, not as a default workaround.
|
|
113
|
+
- **`--force was passed without --force-reason` refusal:** a `--force` flag was about to be sent with no accompanying justification. Stop and get (or write) an explicit reason tied to what the user actually confirmed before retrying — never pass a placeholder string just to satisfy the flag.
|
|
114
|
+
- **A `gh`/`git` call hangs or times out:** every subprocess call has a 30-second timeout (`ETIMEDOUT` surfaces in the error message). A timeout on `detect`/`plan` usually means a real GitHub outage or rate-limit — retry once, and if it persists, tell the user rather than looping silently.
|
|
112
115
|
|
|
113
116
|
## Security rules
|
|
114
117
|
|
package/bin/shipflow.js
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
dispatchReleaseWorkflow,
|
|
15
15
|
renameDefaultBranch,
|
|
16
16
|
} from '../lib/apply.mjs';
|
|
17
|
+
import { readFileCapped } from '../lib/gh.mjs';
|
|
17
18
|
|
|
18
19
|
const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
19
20
|
const TEMPLATE_PATH = join(PACKAGE_ROOT, 'templates', 'dev-to-main-automerge.yml.tmpl');
|
|
@@ -24,7 +25,10 @@ function readPackageVersion() {
|
|
|
24
25
|
}
|
|
25
26
|
|
|
26
27
|
function readConfig(path) {
|
|
27
|
-
|
|
28
|
+
// .github/shipflow.json lives in whatever repo --repo points at — a
|
|
29
|
+
// repo-write-editable file, not admin-only — so cap its size before
|
|
30
|
+
// parsing (see lib/gh.mjs's readFileCapped for why).
|
|
31
|
+
return JSON.parse(readFileCapped(path));
|
|
28
32
|
}
|
|
29
33
|
|
|
30
34
|
function defaultConfigPath(repoPath) {
|
|
@@ -102,10 +106,29 @@ function cmdApply(args) {
|
|
|
102
106
|
config: { type: 'string' },
|
|
103
107
|
'dry-run': { type: 'boolean', default: false },
|
|
104
108
|
'expect-state-hash': { type: 'string' },
|
|
109
|
+
'skip-hash-check': { type: 'boolean', default: false },
|
|
105
110
|
force: { type: 'string', multiple: true, default: [] },
|
|
111
|
+
'force-reason': { type: 'string' },
|
|
106
112
|
},
|
|
107
113
|
});
|
|
108
114
|
if (!values.repo) return fail('apply: --repo is required');
|
|
115
|
+
if (values.force.length > 0 && !values['force-reason']) {
|
|
116
|
+
return fail(
|
|
117
|
+
'apply: --force was passed without --force-reason — every force override must carry a short, explicit human-readable justification (surfaced back in the apply result for auditability)'
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
// --expect-state-hash is the TOCTOU guard: without it, a real apply
|
|
121
|
+
// proceeds against whatever live state happens to exist at call time,
|
|
122
|
+
// with no confirmation that a human/agent actually reviewed that exact
|
|
123
|
+
// state via `plan` first. Omitting it silently used to just skip the
|
|
124
|
+
// check — now it's a hard refusal unless the caller explicitly opts out
|
|
125
|
+
// via --skip-hash-check (a named, greppable escape hatch, not a default).
|
|
126
|
+
// Found via a Siege security audit (2026-07-15, SIEGE-2026-07-15-003).
|
|
127
|
+
if (!values['dry-run'] && !values['expect-state-hash'] && !values['skip-hash-check']) {
|
|
128
|
+
return fail(
|
|
129
|
+
'apply: --expect-state-hash is required for a real apply — pass the stateHash a prior `plan` call returned, or --skip-hash-check to explicitly bypass the drift guard (not recommended)'
|
|
130
|
+
);
|
|
131
|
+
}
|
|
109
132
|
|
|
110
133
|
const configPath = values.config ?? defaultConfigPath(values.repo);
|
|
111
134
|
let config;
|
|
@@ -154,6 +177,7 @@ function cmdApply(args) {
|
|
|
154
177
|
dryRun: values['dry-run'],
|
|
155
178
|
currentStateHash: repoState.stateHash,
|
|
156
179
|
force: values.force,
|
|
180
|
+
forceReason: values['force-reason'] ?? null,
|
|
157
181
|
ownerRepo,
|
|
158
182
|
repoPath: values.repo,
|
|
159
183
|
config,
|
|
@@ -237,7 +261,7 @@ Usage: shipflow <command> [options]
|
|
|
237
261
|
Commands:
|
|
238
262
|
detect --repo <path> [--main <name>] [--dev <name>] [--release-credential <name>]
|
|
239
263
|
plan --repo <path> [--config <path>]
|
|
240
|
-
apply --repo <path> [--config <path>] [--dry-run] [--expect-state-hash <hash>] [--force <id>]...
|
|
264
|
+
apply --repo <path> [--config <path>] [--dry-run] [--expect-state-hash <hash> | --skip-hash-check] [--force <id>]... [--force-reason <text>]
|
|
241
265
|
releases --repo <path> [--config <path>]
|
|
242
266
|
release-dispatch --repo <path> --pr <number> --workflow-file <file>... --ref <ref>
|
|
243
267
|
rename-default-branch --repo <path> --branch <current-name> --to <new-name>
|
package/lib/apply.mjs
CHANGED
|
@@ -30,7 +30,7 @@ export function classifyRulesetError(stderr) {
|
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
export function applyPlan(plan, opts) {
|
|
33
|
-
const { dryRun, currentStateHash, force = [], ownerRepo, repoPath, config } = opts;
|
|
33
|
+
const { dryRun, currentStateHash, force = [], forceReason = null, ownerRepo, repoPath, config } = opts;
|
|
34
34
|
|
|
35
35
|
// TOCTOU guard — refuse before making ANY mutating call if live state has
|
|
36
36
|
// drifted since the plan was computed. Idempotency is the primary safety
|
|
@@ -88,7 +88,12 @@ export function applyPlan(plan, opts) {
|
|
|
88
88
|
|
|
89
89
|
const result = applyOne(entry, { ownerRepo, repoPath, config });
|
|
90
90
|
if (result.ok) {
|
|
91
|
-
|
|
91
|
+
const wasForced = force.includes(entry.id) || (entry.id.startsWith('template:') && force.includes('allow-no-checks'));
|
|
92
|
+
applied.push({
|
|
93
|
+
id: entry.id,
|
|
94
|
+
description: entry.description,
|
|
95
|
+
...(wasForced ? { forced: true, forceReason } : {}),
|
|
96
|
+
});
|
|
92
97
|
if (entry.id.startsWith('template:') && entry.renderedHash) {
|
|
93
98
|
renderedTemplateHashes[entry.path] = entry.renderedHash;
|
|
94
99
|
}
|
package/lib/detect.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { existsSync
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
-
import { spawnArgs, ghApiJson, git, sha256 } from './gh.mjs';
|
|
3
|
+
import { spawnArgs, ghApiJson, git, sha256, readFileCapped } from './gh.mjs';
|
|
4
4
|
|
|
5
5
|
const TEMPLATE_RELATIVE_PATH = '.github/workflows/dev-to-main-automerge.yml';
|
|
6
6
|
const CONFIG_RELATIVE_PATH = '.github/shipflow.json';
|
|
@@ -14,11 +14,20 @@ const SETTINGS_AS_CODE_NAME_RE = /repo-settings\.(sh|js|mjs|py)$/i;
|
|
|
14
14
|
const SETTINGS_AS_CODE_CONTENT_RE =
|
|
15
15
|
/github_branch_protection|github_repository_ruleset|branches\/[\w-]+\/protection/;
|
|
16
16
|
|
|
17
|
+
// A segment made of only dots ("." / ".." / "...") matches [\w.-]+ but is
|
|
18
|
+
// never a real GitHub owner or repo name — reject it rather than build an
|
|
19
|
+
// ownerRepo string that could normalize away the intended repos/<owner>/<repo>
|
|
20
|
+
// prefix once interpolated into gh api paths downstream.
|
|
21
|
+
const ALL_DOTS_RE = /^\.+$/;
|
|
22
|
+
|
|
17
23
|
export function resolveOwnerRepo(repoPath) {
|
|
18
24
|
const r = git(['remote', 'get-url', 'origin'], { cwd: repoPath });
|
|
19
25
|
if (r.status !== 0) return null;
|
|
20
26
|
const m = r.stdout.match(/github\.com[:/]([\w.-]+)\/([\w.-]+?)(\.git)?$/);
|
|
21
|
-
|
|
27
|
+
if (!m) return null;
|
|
28
|
+
const [, owner, repo] = m;
|
|
29
|
+
if (ALL_DOTS_RE.test(owner) || ALL_DOTS_RE.test(repo)) return null;
|
|
30
|
+
return `${owner}/${repo}`;
|
|
22
31
|
}
|
|
23
32
|
|
|
24
33
|
export function listTrackedFiles(repoPath) {
|
|
@@ -42,7 +51,7 @@ export function findSettingsAsCodeArtifact(repoPath, trackedFiles) {
|
|
|
42
51
|
if (!existsSync(full)) continue;
|
|
43
52
|
let content;
|
|
44
53
|
try {
|
|
45
|
-
content =
|
|
54
|
+
content = readFileCapped(full);
|
|
46
55
|
} catch {
|
|
47
56
|
continue;
|
|
48
57
|
}
|
|
@@ -66,7 +75,7 @@ export function listWorkflowJobNames(repoPath, trackedFiles) {
|
|
|
66
75
|
if (!existsSync(full)) continue;
|
|
67
76
|
let content;
|
|
68
77
|
try {
|
|
69
|
-
content =
|
|
78
|
+
content = readFileCapped(full);
|
|
70
79
|
} catch {
|
|
71
80
|
continue;
|
|
72
81
|
}
|
|
@@ -87,7 +96,7 @@ export function listWorkflowJobNames(repoPath, trackedFiles) {
|
|
|
87
96
|
export function readTemplateFileHash(repoPath) {
|
|
88
97
|
const full = join(repoPath, TEMPLATE_RELATIVE_PATH);
|
|
89
98
|
if (!existsSync(full)) return { exists: false, sha256: null };
|
|
90
|
-
const content =
|
|
99
|
+
const content = readFileCapped(full);
|
|
91
100
|
return { exists: true, sha256: sha256(content) };
|
|
92
101
|
}
|
|
93
102
|
|
|
@@ -95,14 +104,14 @@ export function readExistingConfig(repoPath) {
|
|
|
95
104
|
const full = join(repoPath, CONFIG_RELATIVE_PATH);
|
|
96
105
|
if (!existsSync(full)) return null;
|
|
97
106
|
try {
|
|
98
|
-
return JSON.parse(
|
|
107
|
+
return JSON.parse(readFileCapped(full));
|
|
99
108
|
} catch {
|
|
100
109
|
return null;
|
|
101
110
|
}
|
|
102
111
|
}
|
|
103
112
|
|
|
104
113
|
export function fetchBranchProtection(ownerRepo, branch) {
|
|
105
|
-
const r = ghApiJson(`repos/${ownerRepo}/branches/${branch}/protection`);
|
|
114
|
+
const r = ghApiJson(`repos/${ownerRepo}/branches/${encodeURIComponent(branch)}/protection`);
|
|
106
115
|
if (!r.ok) return null;
|
|
107
116
|
const checks = r.data?.required_status_checks?.contexts ?? [];
|
|
108
117
|
return { requiredChecks: checks, raw: r.data };
|
|
@@ -133,7 +142,7 @@ export function fetchRulesetRequiredChecks(ownerRepo, rulesetId) {
|
|
|
133
142
|
}
|
|
134
143
|
|
|
135
144
|
export function checkSecretPresent(ownerRepo, secretName) {
|
|
136
|
-
const r = ghApiJson(`repos/${ownerRepo}/actions/secrets/${secretName}`);
|
|
145
|
+
const r = ghApiJson(`repos/${ownerRepo}/actions/secrets/${encodeURIComponent(secretName)}`);
|
|
137
146
|
return r.ok;
|
|
138
147
|
}
|
|
139
148
|
|
package/lib/gh.mjs
CHANGED
|
@@ -1,16 +1,50 @@
|
|
|
1
1
|
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { statSync, readFileSync } from 'node:fs';
|
|
2
3
|
import { createHash } from 'node:crypto';
|
|
3
4
|
|
|
5
|
+
// Every file shipflow reads under a target repo (.github/shipflow.json,
|
|
6
|
+
// candidate settings-as-code artifacts, workflow YAML, the rendered
|
|
7
|
+
// template on disk) is repo-write-controlled, not admin-only — a
|
|
8
|
+
// maliciously huge or pathologically nested file could exhaust memory on
|
|
9
|
+
// an unbounded readFileSync/JSON.parse. 1 MB is generous for any
|
|
10
|
+
// legitimate config/workflow/IaC file shipflow actually needs to read.
|
|
11
|
+
const MAX_READ_BYTES = 1_000_000;
|
|
12
|
+
|
|
13
|
+
export function readFileCapped(path, encoding = 'utf8') {
|
|
14
|
+
const size = statSync(path).size;
|
|
15
|
+
if (size > MAX_READ_BYTES) {
|
|
16
|
+
throw new Error(`refusing to read ${path}: ${size} bytes exceeds the ${MAX_READ_BYTES}-byte safety cap`);
|
|
17
|
+
}
|
|
18
|
+
return readFileSync(path, encoding);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Every gh/git call crosses a network or filesystem-lock boundary shipflow
|
|
22
|
+
// doesn't control (GitHub rate-limiting, a network partition, a stuck git
|
|
23
|
+
// index lock) — with no timeout, a single stuck call hangs the whole
|
|
24
|
+
// process indefinitely with no recovery. 30s is generous for any real gh
|
|
25
|
+
// API call or local git operation this codebase makes. Found via a Siege
|
|
26
|
+
// security audit (2026-07-15, SIEGE-2026-07-15-004).
|
|
27
|
+
const DEFAULT_SUBPROCESS_TIMEOUT_MS = 30_000;
|
|
28
|
+
|
|
4
29
|
// argv-style invocation; no shell, so user-supplied args cannot inject.
|
|
5
30
|
// Returns { status, stdout, stderr } so callers can distinguish failure modes
|
|
6
31
|
// (e.g. a 404 from gh vs. a network error) instead of collapsing to a boolean.
|
|
7
32
|
export function spawnArgs(cmd, args, opts = {}) {
|
|
8
33
|
try {
|
|
9
|
-
const r = spawnSync(cmd, args, {
|
|
34
|
+
const r = spawnSync(cmd, args, {
|
|
35
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
36
|
+
encoding: 'utf8',
|
|
37
|
+
timeout: DEFAULT_SUBPROCESS_TIMEOUT_MS,
|
|
38
|
+
...opts,
|
|
39
|
+
});
|
|
40
|
+
const stderr = (r.stderr || '').trim();
|
|
41
|
+
// spawnSync does not throw on timeout — it kills the child and sets
|
|
42
|
+
// r.error (code 'ETIMEDOUT') with r.status left null, so this must be
|
|
43
|
+
// surfaced explicitly rather than silently collapsing to a bare "1".
|
|
10
44
|
return {
|
|
11
45
|
status: r.status ?? 1,
|
|
12
46
|
stdout: (r.stdout || '').trim(),
|
|
13
|
-
stderr:
|
|
47
|
+
stderr: r.error ? [stderr, r.error.message].filter(Boolean).join(': ') : stderr,
|
|
14
48
|
};
|
|
15
49
|
} catch (e) {
|
|
16
50
|
return { status: 1, stdout: '', stderr: String((e && e.message) || e) };
|
package/package.json
CHANGED
|
@@ -1,15 +1,30 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@natjswenson/shipflow",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.5",
|
|
4
4
|
"description": "Scaffold a configurable dev/main branching, auto-merge, branch-cleanup, and release-tagging workflow into any repo",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Nate Swenson",
|
|
7
7
|
"homepage": "https://github.com/natejswenson/shipflow",
|
|
8
|
-
"repository": {
|
|
9
|
-
|
|
10
|
-
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/natejswenson/shipflow.git"
|
|
11
|
+
},
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/natejswenson/shipflow/issues"
|
|
14
|
+
},
|
|
15
|
+
"keywords": [
|
|
16
|
+
"shipflow",
|
|
17
|
+
"claude-code",
|
|
18
|
+
"claude-skill",
|
|
19
|
+
"git",
|
|
20
|
+
"branching",
|
|
21
|
+
"release-automation",
|
|
22
|
+
"ci-cd"
|
|
23
|
+
],
|
|
11
24
|
"type": "module",
|
|
12
|
-
"bin": {
|
|
25
|
+
"bin": {
|
|
26
|
+
"shipflow": "bin/shipflow.js"
|
|
27
|
+
},
|
|
13
28
|
"files": [
|
|
14
29
|
"bin/",
|
|
15
30
|
"lib/",
|
|
@@ -21,10 +36,14 @@
|
|
|
21
36
|
"README.md",
|
|
22
37
|
"LICENSE"
|
|
23
38
|
],
|
|
24
|
-
"engines": {
|
|
39
|
+
"engines": {
|
|
40
|
+
"node": ">=18"
|
|
41
|
+
},
|
|
25
42
|
"scripts": {
|
|
26
43
|
"test": "node --test \"tests/**/*.test.mjs\"",
|
|
27
44
|
"audit": "npm audit --audit-level=moderate"
|
|
28
45
|
},
|
|
29
|
-
"
|
|
46
|
+
"devDependencies": {
|
|
47
|
+
"yaml": "^2.9.0"
|
|
48
|
+
}
|
|
30
49
|
}
|
package/skill-invariants.json
CHANGED
|
@@ -65,6 +65,16 @@
|
|
|
65
65
|
"id": "render-template-validates-substitutions",
|
|
66
66
|
"pattern": "never assume a config field is pre-sanitized",
|
|
67
67
|
"rationale": "Found by a Siege security audit (2026-07-15): renderTemplate did pure string substitution with zero escaping, so a repo-write-level (not admin-level) edit to .github/shipflow.json's branch names or release.releaseCredential could break out of the rendered YAML's quoting and make the auto-merge job's if: condition unconditionally true, or inject arbitrary new workflow steps — a privilege escalation via the credential the rendered workflow runs with."
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
"id": "expect-state-hash-mandatory",
|
|
71
|
+
"pattern": "is mandatory for a real \\(non-dry-run\\) apply",
|
|
72
|
+
"rationale": "Found by a Siege security audit (2026-07-15, SIEGE-2026-07-15-003): --expect-state-hash was optional, so a real apply could silently proceed with zero TOCTOU/drift protection if the caller simply omitted it. Now a hard CLI refusal unless the named --skip-hash-check escape hatch is passed explicitly."
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
"id": "force-requires-reason",
|
|
76
|
+
"pattern": "refuses any `--force` without an accompanying reason",
|
|
77
|
+
"rationale": "Found by a Siege security audit (2026-07-15, SIEGE-2026-07-15-002): --force had zero code-level friction beyond the flag itself, so a confused or prompt-injected agent could force through an unprotected merge or a tampered template with no audit trail. --force now requires --force-reason, echoed back in the apply result."
|
|
68
78
|
}
|
|
69
79
|
],
|
|
70
80
|
"cli_commands_referenced": ["detect", "plan", "apply", "releases", "release-dispatch", "rename-default-branch"]
|