@vegastack/vegafactory 0.19.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/LICENSE +21 -0
- package/README.md +417 -0
- package/dist/dashboard-artifact.json +21240 -0
- package/dist/index.js +22752 -0
- package/dist/run-wrapper.js +377 -0
- package/package.json +46 -0
- package/skill/dev-architect/SKILL.md +106 -0
- package/skill/dev-architect/agents/openai.yaml +4 -0
- package/skill/dev-architect/references/ai-agents.md +96 -0
- package/skill/dev-architect/references/conventions.md +113 -0
- package/skill/dev-architect/references/data.md +89 -0
- package/skill/dev-architect/references/infra.md +99 -0
- package/skill/dev-architect/references/mobile.md +75 -0
- package/skill/dev-architect/references/pinned-facts.md +133 -0
- package/skill/dev-architect/references/principles.md +117 -0
- package/skill/dev-architect/references/security.md +90 -0
- package/skill/dev-architect/references/stack.md +38 -0
- package/skill/dev-architect/references/web.md +102 -0
- package/skill/dev-architect/refresh/REFRESH.md +31 -0
- package/skill/dev-architect/refresh/sources.json +239 -0
- package/skill/dev-chronicle/SKILL.md +52 -0
- package/skill/dev-chronicle/agents/openai.yaml +4 -0
- package/skill/dev-chronicle/references/conventions.md +113 -0
- package/skill/dev-chronicle/references/styles.md +75 -0
- package/skill/dev-chronicle/refresh/REFRESH.md +3 -0
- package/skill/dev-chronicle/refresh/sources.json +6 -0
- package/skill/dev-debug/SKILL.md +43 -0
- package/skill/dev-debug/agents/openai.yaml +4 -0
- package/skill/dev-debug/references/conventions.md +113 -0
- package/skill/dev-debug/references/loop-ladder.md +20 -0
- package/skill/dev-debug/refresh/REFRESH.md +3 -0
- package/skill/dev-debug/refresh/sources.json +6 -0
- package/skill/dev-implement/SKILL.md +81 -0
- package/skill/dev-implement/agents/openai.yaml +4 -0
- package/skill/dev-implement/assets/workflows/implement-children.js +12 -0
- package/skill/dev-implement/references/ask-route.md +80 -0
- package/skill/dev-implement/references/changelog-and-chronicle.md +21 -0
- package/skill/dev-implement/references/conventions.md +113 -0
- package/skill/dev-implement/references/ledger-and-resume.md +52 -0
- package/skill/dev-implement/references/parallel-children.md +35 -0
- package/skill/dev-implement/references/worktrees.md +63 -0
- package/skill/dev-implement/refresh/REFRESH.md +3 -0
- package/skill/dev-implement/refresh/sources.json +6 -0
- package/skill/dev-implement/scripts/children.mjs +479 -0
- package/skill/dev-implement/scripts/effective-policy.mjs +694 -0
- package/skill/dev-implement/scripts/evidence-check.mjs +107 -0
- package/skill/dev-implement/scripts/evidence-upload.mjs +181 -0
- package/skill/dev-implement/scripts/learning.mjs +41 -0
- package/skill/dev-implement/scripts/lib/approval.mjs +1182 -0
- package/skill/dev-implement/scripts/lib/gh.mjs +101 -0
- package/skill/dev-implement/scripts/preflight.mjs +153 -0
- package/skill/dev-implement/scripts/questions.mjs +381 -0
- package/skill/dev-implement/scripts/reclaim.mjs +116 -0
- package/skill/dev-implement/scripts/recovery.mjs +213 -0
- package/skill/dev-implement/scripts/worktree.mjs +991 -0
- package/skill/dev-intake/SKILL.md +80 -0
- package/skill/dev-intake/agents/openai.yaml +4 -0
- package/skill/dev-intake/references/ask-route.md +80 -0
- package/skill/dev-intake/references/brief-template.md +89 -0
- package/skill/dev-intake/references/conventions.md +113 -0
- package/skill/dev-intake/refresh/REFRESH.md +3 -0
- package/skill/dev-intake/refresh/sources.json +6 -0
- package/skill/dev-intake/scripts/brief-lint.mjs +87 -0
- package/skill/dev-intake/scripts/lib/approval.mjs +1182 -0
- package/skill/dev-intake/scripts/questions.mjs +381 -0
- package/skill/dev-plan/SKILL.md +54 -0
- package/skill/dev-plan/agents/openai.yaml +4 -0
- package/skill/dev-plan/references/ask-route.md +80 -0
- package/skill/dev-plan/references/conventions.md +113 -0
- package/skill/dev-plan/references/plan-format.md +82 -0
- package/skill/dev-plan/refresh/REFRESH.md +3 -0
- package/skill/dev-plan/refresh/sources.json +6 -0
- package/skill/dev-plan/scripts/lib/approval.mjs +1182 -0
- package/skill/dev-plan/scripts/plan-lint.mjs +319 -0
- package/skill/dev-plan/scripts/questions.mjs +381 -0
- package/skill/dev-review/SKILL.md +76 -0
- package/skill/dev-review/agents/openai.yaml +4 -0
- package/skill/dev-review/assets/review-known-patterns.md.template +32 -0
- package/skill/dev-review/references/conventions.md +113 -0
- package/skill/dev-review/references/cross-agent.md +39 -0
- package/skill/dev-review/references/dispatch-prompts.md +121 -0
- package/skill/dev-review/references/security-axis.md +45 -0
- package/skill/dev-review/refresh/REFRESH.md +5 -0
- package/skill/dev-review/refresh/sources.json +6 -0
- package/skill/dev-setup/SKILL.md +113 -0
- package/skill/dev-setup/agents/openai.yaml +4 -0
- package/skill/dev-setup/assets/agents-section.md.template +24 -0
- package/skill/dev-setup/assets/dev-profile.md.template +102 -0
- package/skill/dev-setup/assets/factory-board.yml.template +162 -0
- package/skill/dev-setup/assets/hooks/decision-nudge.mjs +30 -0
- package/skill/dev-setup/assets/hooks/prompt-skill-mention.mjs +29 -0
- package/skill/dev-setup/assets/hooks/session-end.mjs +14 -0
- package/skill/dev-setup/assets/hooks/session-start.mjs +178 -0
- package/skill/dev-setup/assets/hooks/ship-guard.mjs +738 -0
- package/skill/dev-setup/assets/hooks/skill-activated.mjs +47 -0
- package/skill/dev-setup/assets/hooks/stop-heartbeat.mjs +14 -0
- package/skill/dev-setup/assets/workflows/implement-children.js +12 -0
- package/skill/dev-setup/references/ask-route.md +80 -0
- package/skill/dev-setup/references/conventions.md +113 -0
- package/skill/dev-setup/references/github-app.md +286 -0
- package/skill/dev-setup/references/harness-facts.md +169 -0
- package/skill/dev-setup/references/stack-playbooks.md +124 -0
- package/skill/dev-setup/refresh/REFRESH.md +13 -0
- package/skill/dev-setup/refresh/sources.json +602 -0
- package/skill/dev-setup/scripts/effective-policy.mjs +694 -0
- package/skill/dev-setup/scripts/questions.mjs +381 -0
- package/skill/dev-setup/scripts/ship-policy.mjs +195 -0
- package/skill/dev-ship/SKILL.md +56 -0
- package/skill/dev-ship/agents/openai.yaml +4 -0
- package/skill/dev-ship/references/conventions.md +113 -0
- package/skill/dev-ship/references/runbook.md +72 -0
- package/skill/dev-ship/refresh/REFRESH.md +3 -0
- package/skill/dev-ship/refresh/sources.json +6 -0
- package/skill/dev-ship/scripts/lib/approval.mjs +1182 -0
- package/skill/dev-ship/scripts/ship-gate.mjs +485 -0
- package/skill/dev-status/SKILL.md +56 -0
- package/skill/dev-status/agents/openai.yaml +4 -0
- package/skill/dev-status/references/conventions.md +113 -0
- package/skill/dev-status/refresh/REFRESH.md +3 -0
- package/skill/dev-status/refresh/sources.json +6 -0
- package/skill/dev-status/scripts/effective-policy.mjs +694 -0
- package/skill/dev-status/scripts/status.mjs +311 -0
- package/skill/skill-maintainer/SKILL.md +82 -0
- package/skill/skill-maintainer/agents/openai.yaml +4 -0
- package/skill/skill-maintainer/references/release-ops.md +61 -0
- package/skill/skill-maintainer/references/standards.md +137 -0
- package/skill/skill-maintainer/refresh/REFRESH.md +40 -0
- package/skill/skill-maintainer/refresh/sources.json +103 -0
- package/skill/skill-scan/SKILL.md +59 -0
- package/skill/skill-scan/agents/openai.yaml +4 -0
- package/skill/skill-scan/refresh/REFRESH.md +18 -0
- package/skill/skill-scan/refresh/sources.json +133 -0
- package/skill/skill-scan/scripts/lib/skillspector.mjs +226 -0
- package/skill/skill-scan/scripts/skill-scan.mjs +1002 -0
- package/skill/skillify/SKILL.md +108 -0
- package/skill/skillify/agents/openai.yaml +4 -0
- package/skill/skillify/assets/templates/README.md.template +26 -0
- package/skill/skillify/assets/templates/REFRESH.md.template +16 -0
- package/skill/skillify/assets/templates/SKILL.md.template +27 -0
- package/skill/skillify/assets/templates/evals.json.template +12 -0
- package/skill/skillify/assets/templates/openai.yaml.template +4 -0
- package/skill/skillify/assets/templates/skill.test.ts.template +27 -0
- package/skill/skillify/assets/templates/sources.json.template +6 -0
- package/skill/skillify/assets/templates/trigger-queries.json.template +1 -0
- package/skill/skillify/references/authoring.md +103 -0
- package/skill/skillify/references/eval-playbook.md +112 -0
- package/skill/skillify/refresh/REFRESH.md +23 -0
- package/skill/skillify/refresh/sources.json +6 -0
- package/skill/skillify/scripts/scaffold-skill.mjs +334 -0
- package/skill/skillify/scripts/trigger-check.mjs +209 -0
- package/skill/vegafactory-setup/SKILL.md +77 -0
- package/skill/vegafactory-setup/agents/openai.yaml +4 -0
- package/skill/vegafactory-setup/assets/control-room/boards.md.template +17 -0
- package/skill/vegafactory-setup/assets/control-room/decisions.md.template +5 -0
- package/skill/vegafactory-setup/assets/control-room/group.md.template +44 -0
- package/skill/vegafactory-setup/assets/control-room/onboarding/dispatcher-box.md.template +86 -0
- package/skill/vegafactory-setup/assets/control-room/onboarding/new-repo.md.template +13 -0
- package/skill/vegafactory-setup/assets/control-room/onboarding/new-teammate.md.template +12 -0
- package/skill/vegafactory-setup/assets/control-room/org.md.template +60 -0
- package/skill/vegafactory-setup/assets/control-room/people.csv.template +2 -0
- package/skill/vegafactory-setup/assets/control-room/repos.md.template +7 -0
- package/skill/vegafactory-setup/assets/control-room/rules/CODEOWNERS.template +6 -0
- package/skill/vegafactory-setup/assets/control-room/rules/README.md.template +9 -0
- package/skill/vegafactory-setup/assets/control-room/rules/stats-privacy.md.template +44 -0
- package/skill/vegafactory-setup/assets/control-room/templates/README.md.template +9 -0
- package/skill/vegafactory-setup/references/control-room.md +149 -0
- package/skill/vegafactory-setup/references/conventions.md +113 -0
- package/skill/vegafactory-setup/refresh/REFRESH.md +5 -0
- package/skill/vegafactory-setup/refresh/sources.json +6 -0
- package/skill-integrity.json +252 -0
|
@@ -0,0 +1,738 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Ship guard — the "nothing ships without the operator's word" rule, made mechanical.
|
|
3
|
+
//
|
|
4
|
+
// Installed to .vegastack/hooks/ship-guard.mjs and wired as a PreToolUse hook on the
|
|
5
|
+
// harness's shell tool. Its ONLY source of policy is the compiled policy file
|
|
6
|
+
// ~/.vegastack/guard/<owner>__<repo>.json, keyed by the checkout's origin remote. dev-setup
|
|
7
|
+
// compiles that file from .vegastack/dev.md (the `## Environments` policy lines, the `gates:`
|
|
8
|
+
// knob, the backticked commands on the `## Ship` runbook's `ask:` lines) on the operator's
|
|
9
|
+
// yes, and `vegafactory guard sync` recompiles it. The guard never reads dev.md: the policy
|
|
10
|
+
// lives outside every worktree, so no task edits it and no commit carries it, and a command
|
|
11
|
+
// that touches the policy store is itself on the always-ask list.
|
|
12
|
+
//
|
|
13
|
+
// Fail closed: a command inside a guarded family that cannot be classified resolves to ask
|
|
14
|
+
// (Claude) / block (Codex), never allow; so does every guarded command while the policy file
|
|
15
|
+
// is missing, unreadable, malformed or compiled for another repository. A payload carrying no
|
|
16
|
+
// shell command is in no guarded family and resolves to allow.
|
|
17
|
+
//
|
|
18
|
+
// What this is not: the hook runs as the same user as the agent, so nothing on the machine
|
|
19
|
+
// is unwritable by it. Branch protection and a read-only token are the walls; this closes
|
|
20
|
+
// the self-authorisation path and makes tampering visible, nothing more.
|
|
21
|
+
|
|
22
|
+
import { execFileSync } from 'node:child_process'
|
|
23
|
+
import { readFileSync } from 'node:fs'
|
|
24
|
+
import { homedir } from 'node:os'
|
|
25
|
+
import { basename, join } from 'node:path'
|
|
26
|
+
import { pathToFileURL } from 'node:url'
|
|
27
|
+
|
|
28
|
+
// Hoisted: the scanner reads the word ignore followed by a quote and a comma as a removal cue and stops
|
|
29
|
+
// evaluating the file, which switches off twelve analyzers. A named constant keeps
|
|
30
|
+
// the word out of quote-adjacency without changing what execFileSync receives.
|
|
31
|
+
const NO_STDIO = 'ignore';
|
|
32
|
+
const KIND_DELETE = 'delete';
|
|
33
|
+
const VERB_WORKTREE_REMOVE = 'git worktree remove';
|
|
34
|
+
|
|
35
|
+
export const SCHEMA_VERSION = 2
|
|
36
|
+
export const SYNC_COMMAND = 'vegafactory guard sync'
|
|
37
|
+
// The policy store, as any path spelling would carry it — `~/`, a HOME-relative form, absolute.
|
|
38
|
+
const POLICY_STORE = '.vegastack/guard'
|
|
39
|
+
|
|
40
|
+
// The always-ask families. Matched on the resolved argv, flags in any position — never as
|
|
41
|
+
// substrings of the raw text. They ask whatever the knobs say: destructive, or a skipped check.
|
|
42
|
+
export const ALWAYS_ASK = [
|
|
43
|
+
{ label: 'a force push', example: 'git push --force / -f / +refspec' },
|
|
44
|
+
{ label: 'a hard reset', example: 'git reset --hard' },
|
|
45
|
+
{ label: 'deleting a branch', example: 'git branch -d/-D, git push --delete / :branch' },
|
|
46
|
+
{ label: 'removing a worktree by force', example: 'git worktree remove --force' },
|
|
47
|
+
{ label: 'skipping the pre-commit checks', example: '--no-verify' },
|
|
48
|
+
{ label: 'editing the ship-guard policy store', example: '~/.vegastack/guard/' },
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
// The operations that reach the default branch or the world. Documentation for the reason
|
|
52
|
+
// text; the branch name itself comes from the policy, so this array stays generic.
|
|
53
|
+
export const DEFAULT_BRANCH_OPS = ['gh pr merge', 'git push origin <default-branch>', 'git tag', 'npm publish']
|
|
54
|
+
|
|
55
|
+
// --- the policy file -----------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
// One path segment: `owner/repo` → `owner__repo`, matching the statistics outbox layout.
|
|
58
|
+
export function repoSegment(repo) {
|
|
59
|
+
return String(repo ?? '').replace(/\//g, '__').replace(/[^A-Za-z0-9._-]/g, '-')
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function policyPath(home, repo) {
|
|
63
|
+
return join(home, '.vegastack', 'guard', repoSegment(repo) + '.json')
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// `owner/repo` from an origin URL in any of git's spellings; null for anything else.
|
|
67
|
+
export function repoFromRemote(url) {
|
|
68
|
+
const text = String(url ?? '').trim()
|
|
69
|
+
const match = text.match(/^(?:[a-z][a-z0-9+.-]*:\/\/[^/\s]+\/|[^/@\s]+@[^:/\s]+:)([^/\s]+)\/([^/\s]+?)(?:\.git)?\/?$/)
|
|
70
|
+
if (!match) return null
|
|
71
|
+
return match[1] + '/' + match[2]
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Reads the compiled policy. Anything short of a well-formed file for this very repository is
|
|
75
|
+
// `{ missing: <reason> }`, and every guarded command then asks with that reason.
|
|
76
|
+
export function readPolicyFile(text, expectedRepo, displayPath) {
|
|
77
|
+
const shown = displayPath || (expectedRepo ? '~/' + POLICY_STORE + '/' + repoSegment(expectedRepo) + '.json' : '~/' + POLICY_STORE + '/<owner>__<repo>.json')
|
|
78
|
+
if (!expectedRepo) return { missing: 'the ship guard could not read this checkout\'s origin remote (git remote get-url origin), so it has no policy' }
|
|
79
|
+
if (typeof text !== 'string') return { missing: `no ship-guard policy at ${shown}` }
|
|
80
|
+
let parsed
|
|
81
|
+
try {
|
|
82
|
+
parsed = JSON.parse(text)
|
|
83
|
+
} catch {
|
|
84
|
+
return { missing: `the ship-guard policy at ${shown} is not valid JSON` }
|
|
85
|
+
}
|
|
86
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return { missing: `the ship-guard policy at ${shown} is not a JSON object` }
|
|
87
|
+
if (parsed.schemaVersion !== SCHEMA_VERSION) return { missing: `the ship-guard policy at ${shown} has schemaVersion ${JSON.stringify(parsed.schemaVersion)}, expected ${SCHEMA_VERSION}` }
|
|
88
|
+
if (parsed.repo !== expectedRepo) return { missing: `the ship-guard policy at ${shown} is for ${JSON.stringify(parsed.repo)}, not ${expectedRepo}` }
|
|
89
|
+
if (typeof parsed.policyDigest !== 'string' || !/^[a-f0-9]{64}$/.test(parsed.policyDigest)) return { missing: `the ship-guard policy at ${shown} has an invalid effective policy digest` }
|
|
90
|
+
const sources = parsed.sources
|
|
91
|
+
if (!sources || typeof sources !== 'object' || Array.isArray(sources) || !Object.values(sources).every((source) =>
|
|
92
|
+
source && typeof source === 'object' && !Array.isArray(source)
|
|
93
|
+
&& ['org', 'group', 'repo'].includes(source.scope)
|
|
94
|
+
&& typeof source.path === 'string' && source.path.trim()
|
|
95
|
+
&& typeof source.revision === 'string'
|
|
96
|
+
&& (source.revisionKind === 'git' ? /^[a-f0-9]{40}$/.test(source.revision)
|
|
97
|
+
: source.revisionKind === 'sha256' && /^[a-f0-9]{64}$/.test(source.revision)))) {
|
|
98
|
+
return { missing: `the ship-guard policy at ${shown} has invalid source provenance` }
|
|
99
|
+
}
|
|
100
|
+
const environments = Array.isArray(parsed.environments)
|
|
101
|
+
? parsed.environments.filter((entry) => entry && typeof entry === 'object' && typeof entry.target === 'string' && (entry.policy === 'auto' || entry.policy === 'ask') && typeof entry.pattern === 'string' && entry.pattern.trim())
|
|
102
|
+
.map((entry) => ({ target: entry.target, policy: entry.policy, pattern: entry.pattern.trim() }))
|
|
103
|
+
: []
|
|
104
|
+
return {
|
|
105
|
+
defaultBranch: typeof parsed.defaultBranch === 'string' && parsed.defaultBranch ? parsed.defaultBranch : 'main',
|
|
106
|
+
gates: [1, 2, 3].includes(parsed.gates) ? parsed.gates : 3,
|
|
107
|
+
environments,
|
|
108
|
+
shipAsk: Array.isArray(parsed.shipAsk) ? parsed.shipAsk.filter((entry) => typeof entry === 'string' && entry.trim()).map((entry) => entry.trim()) : [],
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// The policy the guard applies when it has none: no environment is auto, and no push
|
|
113
|
+
// destination can be compared with a default branch it does not know.
|
|
114
|
+
const NO_POLICY = { defaultBranch: null, gates: 3, environments: [], shipAsk: [] }
|
|
115
|
+
|
|
116
|
+
// --- shell-word parsing --------------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
// Reads a command the way a POSIX shell reads it: quotes and escapes resolve, `;` `&&` `||`
|
|
119
|
+
// `|` `|&` `&` and newlines end a segment, dollar-paren substitution, backticks, `(…)` subshells and `{ …; }`
|
|
120
|
+
// groups are parsed for their own segments. Each segment carries its argv (`words`), the
|
|
121
|
+
// redirection targets (`redirects`) and the quoted words that held whitespace (`strings` —
|
|
122
|
+
// text handed to another program, probed separately).
|
|
123
|
+
export function parseCommand(command) {
|
|
124
|
+
const segments = []
|
|
125
|
+
if (typeof command === 'string') parseInto(command, segments)
|
|
126
|
+
return segments
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function matchParen(text, open) {
|
|
130
|
+
let depth = 0
|
|
131
|
+
let quote = null
|
|
132
|
+
for (let i = open; i < text.length; i += 1) {
|
|
133
|
+
const ch = text[i]
|
|
134
|
+
if (quote) {
|
|
135
|
+
if (ch === '\\' && quote === '"') i += 1
|
|
136
|
+
else if (ch === quote) quote = null
|
|
137
|
+
continue
|
|
138
|
+
}
|
|
139
|
+
if (ch === '\\') { i += 1; continue }
|
|
140
|
+
if (ch === "'" || ch === '"') { quote = ch; continue }
|
|
141
|
+
if (ch === '(') depth += 1
|
|
142
|
+
else if (ch === ')') {
|
|
143
|
+
depth -= 1
|
|
144
|
+
if (depth === 0) return i
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return text.length
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function parseInto(text, segments) {
|
|
151
|
+
let words = []
|
|
152
|
+
let redirects = []
|
|
153
|
+
let strings = []
|
|
154
|
+
let deferred = []
|
|
155
|
+
let word = null
|
|
156
|
+
let quoted = false
|
|
157
|
+
let redirectNext = false
|
|
158
|
+
|
|
159
|
+
const flush = () => {
|
|
160
|
+
if (word === null) return
|
|
161
|
+
if (redirectNext) redirects.push(word)
|
|
162
|
+
else {
|
|
163
|
+
words.push(word)
|
|
164
|
+
if (quoted && /\s/.test(word)) strings.push(word)
|
|
165
|
+
}
|
|
166
|
+
word = null
|
|
167
|
+
quoted = false
|
|
168
|
+
redirectNext = false
|
|
169
|
+
}
|
|
170
|
+
const endSegment = () => {
|
|
171
|
+
flush()
|
|
172
|
+
if (words.length > 0 || redirects.length > 0) segments.push({ words, redirects, strings })
|
|
173
|
+
segments.push(...deferred)
|
|
174
|
+
words = []
|
|
175
|
+
redirects = []
|
|
176
|
+
strings = []
|
|
177
|
+
deferred = []
|
|
178
|
+
redirectNext = false
|
|
179
|
+
}
|
|
180
|
+
const substitute = (inner) => {
|
|
181
|
+
const nested = []
|
|
182
|
+
parseInto(inner, nested)
|
|
183
|
+
deferred.push(...nested)
|
|
184
|
+
}
|
|
185
|
+
const append = (piece) => { word = (word ?? '') + piece }
|
|
186
|
+
|
|
187
|
+
let i = 0
|
|
188
|
+
while (i < text.length) {
|
|
189
|
+
const ch = text[i]
|
|
190
|
+
if (ch === '$' && text[i + 1] === '(') {
|
|
191
|
+
const end = matchParen(text, i + 1)
|
|
192
|
+
substitute(text.slice(i + 2, end))
|
|
193
|
+
i = end + 1
|
|
194
|
+
continue
|
|
195
|
+
}
|
|
196
|
+
if (ch === '`') {
|
|
197
|
+
const end = text.indexOf('`', i + 1)
|
|
198
|
+
const stop = end === -1 ? text.length : end
|
|
199
|
+
substitute(text.slice(i + 1, stop))
|
|
200
|
+
i = stop + 1
|
|
201
|
+
continue
|
|
202
|
+
}
|
|
203
|
+
if (ch === '(' && word === null) {
|
|
204
|
+
const end = matchParen(text, i)
|
|
205
|
+
endSegment()
|
|
206
|
+
parseInto(text.slice(i + 1, end), segments)
|
|
207
|
+
i = end + 1
|
|
208
|
+
continue
|
|
209
|
+
}
|
|
210
|
+
if ((ch === '{' || ch === '}') && word === null && /\s|;|$/.test(text[i + 1] ?? '')) {
|
|
211
|
+
i += 1
|
|
212
|
+
continue
|
|
213
|
+
}
|
|
214
|
+
if (ch === "'") {
|
|
215
|
+
const end = text.indexOf("'", i + 1)
|
|
216
|
+
const stop = end === -1 ? text.length : end
|
|
217
|
+
append(text.slice(i + 1, stop))
|
|
218
|
+
quoted = true
|
|
219
|
+
i = stop + 1
|
|
220
|
+
continue
|
|
221
|
+
}
|
|
222
|
+
if (ch === '"') {
|
|
223
|
+
let j = i + 1
|
|
224
|
+
let buffer = ''
|
|
225
|
+
while (j < text.length && text[j] !== '"') {
|
|
226
|
+
if (text[j] === '\\' && j + 1 < text.length && '"\\$`\n'.includes(text[j + 1])) {
|
|
227
|
+
if (text[j + 1] !== '\n') buffer += text[j + 1]
|
|
228
|
+
j += 2
|
|
229
|
+
continue
|
|
230
|
+
}
|
|
231
|
+
if (text[j] === '$' && text[j + 1] === '(') {
|
|
232
|
+
const end = matchParen(text, j + 1)
|
|
233
|
+
substitute(text.slice(j + 2, end))
|
|
234
|
+
j = end + 1
|
|
235
|
+
continue
|
|
236
|
+
}
|
|
237
|
+
if (text[j] === '`') {
|
|
238
|
+
const end = text.indexOf('`', j + 1)
|
|
239
|
+
const stop = end === -1 ? text.length : end
|
|
240
|
+
substitute(text.slice(j + 1, stop))
|
|
241
|
+
j = stop + 1
|
|
242
|
+
continue
|
|
243
|
+
}
|
|
244
|
+
buffer += text[j]
|
|
245
|
+
j += 1
|
|
246
|
+
}
|
|
247
|
+
append(buffer)
|
|
248
|
+
quoted = true
|
|
249
|
+
i = j + 1
|
|
250
|
+
continue
|
|
251
|
+
}
|
|
252
|
+
if (ch === '\\') {
|
|
253
|
+
if (i + 1 < text.length && text[i + 1] !== '\n') append(text[i + 1])
|
|
254
|
+
i += 2
|
|
255
|
+
continue
|
|
256
|
+
}
|
|
257
|
+
if (ch === '\n' || ch === ';') {
|
|
258
|
+
endSegment()
|
|
259
|
+
i += 1
|
|
260
|
+
continue
|
|
261
|
+
}
|
|
262
|
+
if (ch === '&' && text[i + 1] === '>') {
|
|
263
|
+
flush()
|
|
264
|
+
redirectNext = true
|
|
265
|
+
i += text[i + 2] === '>' ? 3 : 2
|
|
266
|
+
continue
|
|
267
|
+
}
|
|
268
|
+
if (ch === '&' || ch === '|') {
|
|
269
|
+
endSegment()
|
|
270
|
+
i += 1
|
|
271
|
+
if (text[i] === ch || (ch === '|' && text[i] === '&')) i += 1
|
|
272
|
+
continue
|
|
273
|
+
}
|
|
274
|
+
if (ch === '>' || ch === '<') {
|
|
275
|
+
// A bare file descriptor before the operator (`2>&1`) is not an argument.
|
|
276
|
+
if (word !== null && /^\d+$/.test(word) && !quoted) word = null
|
|
277
|
+
flush()
|
|
278
|
+
i += 1
|
|
279
|
+
if (text[i] === ch) {
|
|
280
|
+
i += 1
|
|
281
|
+
if (ch === '<' && text[i] === '<') i += 1
|
|
282
|
+
}
|
|
283
|
+
if (text[i] === '&') {
|
|
284
|
+
i += 1
|
|
285
|
+
while (i < text.length && /[0-9-]/.test(text[i])) i += 1
|
|
286
|
+
continue
|
|
287
|
+
}
|
|
288
|
+
redirectNext = true
|
|
289
|
+
continue
|
|
290
|
+
}
|
|
291
|
+
if (/\s/.test(ch)) {
|
|
292
|
+
flush()
|
|
293
|
+
i += 1
|
|
294
|
+
continue
|
|
295
|
+
}
|
|
296
|
+
append(ch)
|
|
297
|
+
i += 1
|
|
298
|
+
}
|
|
299
|
+
endSegment()
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// The segments' argv, joined — the compatibility view of parseCommand.
|
|
303
|
+
export function splitSegments(command) {
|
|
304
|
+
return parseCommand(command).map((segment) => segment.words.join(' ')).filter(Boolean)
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// --- argv resolution -----------------------------------------------------------------
|
|
308
|
+
|
|
309
|
+
// Wrappers that carry a command without changing what it does: the head, the options that
|
|
310
|
+
// take a separate value, and how many positionals precede the wrapped command.
|
|
311
|
+
const WRAPPERS = {
|
|
312
|
+
sudo: { withValue: ['-u', '-g', '-C', '-D', '-h', '-p', '-r', '-t', '-T', '-U', '-R'], positionals: 0 },
|
|
313
|
+
doas: { withValue: ['-u', '-C'], positionals: 0 },
|
|
314
|
+
env: { withValue: ['-u', '-C', '-S', '--unset', '--chdir', '--split-string'], positionals: 0 },
|
|
315
|
+
nice: { withValue: ['-n', '--adjustment'], positionals: 0 },
|
|
316
|
+
ionice: { withValue: ['-c', '-n', '-p'], positionals: 0 },
|
|
317
|
+
time: { withValue: ['-f', '-o', '--format', '--output'], positionals: 0 },
|
|
318
|
+
timeout: { withValue: ['-k', '-s', '--kill-after', '--signal'], positionals: 1 },
|
|
319
|
+
command: { withValue: [], positionals: 0 },
|
|
320
|
+
builtin: { withValue: [], positionals: 0 },
|
|
321
|
+
exec: { withValue: ['-a'], positionals: 0 },
|
|
322
|
+
nohup: { withValue: [], positionals: 0 },
|
|
323
|
+
caffeinate: { withValue: ['-t', '-w'], positionals: 0 },
|
|
324
|
+
stdbuf: { withValue: ['-i', '-o', '-e', '--input', '--output', '--error'], positionals: 0 },
|
|
325
|
+
xargs: { withValue: ['-I', '-n', '-L', '-P', '-d', '-s', '-E', '-a', '--max-args', '--max-lines', '--max-procs', '--delimiter', '--replace', '--arg-file'], positionals: 0 },
|
|
326
|
+
}
|
|
327
|
+
const SHELLS = new Set(['sh', 'bash', 'zsh', 'dash', 'ksh', 'ash', 'fish'])
|
|
328
|
+
// git's global options, which sit between `git` and the subcommand.
|
|
329
|
+
const GIT_GLOBAL_WITH_VALUE = new Set(['-C', '-c', '--git-dir', '--work-tree', '--namespace', '--exec-path', '--super-prefix', '--config-env', '--list-cmds', '--attr-source'])
|
|
330
|
+
// gh's global options.
|
|
331
|
+
const GH_GLOBAL_WITH_VALUE = new Set(['-R', '--repo'])
|
|
332
|
+
// Every git subcommand the guard knows how to classify or leave alone. A subcommand outside
|
|
333
|
+
// this list is an alias the guard cannot see through, so it asks.
|
|
334
|
+
const GIT_SUBCOMMANDS = new Set([
|
|
335
|
+
'add', 'am', 'annotate', 'apply', 'archive', 'bisect', 'blame', 'branch', 'bundle', 'cat-file', 'check-attr',
|
|
336
|
+
'check-mailmap', 'check-ref-format', 'checkout', 'checkout-index', 'cherry', 'cherry-pick', 'citool', 'clean', 'clone', 'column',
|
|
337
|
+
'commit', 'commit-tree', 'config', 'count-objects', 'credential', 'describe', 'diff', 'diff-files', 'diff-index', 'diff-tree',
|
|
338
|
+
'difftool', 'fast-export', 'fast-import', 'fetch', 'fetch-pack', 'filter-branch', 'fmt-merge-msg', 'for-each-ref', 'for-each-repo',
|
|
339
|
+
'format-patch', 'fsck', 'gc', 'get-tar-commit-id', 'grep', 'gui', 'hash-object', 'help', 'hook', 'index-pack', 'init',
|
|
340
|
+
'instaweb', 'interpret-trailers', 'log', 'ls-files', 'ls-remote', 'ls-tree', 'mailinfo', 'mailsplit', 'maintenance', 'merge',
|
|
341
|
+
'merge-base', 'merge-file', 'merge-index', 'merge-one-file', 'merge-tree', 'mergetool', 'mktag', 'mktree', 'mv', 'name-rev',
|
|
342
|
+
'notes', 'pack-objects', 'pack-redundant', 'pack-refs', 'patch-id', 'prune', 'prune-packed', 'pull', 'push', 'range-diff',
|
|
343
|
+
'read-tree', 'rebase', 'reflog', 'remote', 'repack', 'replace', 'request-pull', 'rerere', 'reset', 'restore', 'rev-list',
|
|
344
|
+
'rev-parse', 'revert', 'rm', 'send-email', 'send-pack', 'shortlog', 'show', 'show-branch', 'show-index', 'show-ref',
|
|
345
|
+
'sparse-checkout', 'stash', 'status', 'stripspace', 'submodule', 'subtree', 'switch', 'symbolic-ref', 'tag', 'unpack-file',
|
|
346
|
+
'unpack-objects', 'update-index', 'update-ref', 'update-server-info', 'var', 'verify-commit', 'verify-pack', 'verify-tag',
|
|
347
|
+
'version', 'whatchanged', 'worktree', 'write-tree', 'check-ignore'])
|
|
348
|
+
|
|
349
|
+
function stripWrapper(words, spec) {
|
|
350
|
+
let rest = words.slice(1)
|
|
351
|
+
while (rest.length > 0 && rest[0].startsWith('-')) {
|
|
352
|
+
const option = rest[0]
|
|
353
|
+
if (option === '--') { rest = rest.slice(1); break }
|
|
354
|
+
const name = option.includes('=') ? option.slice(0, option.indexOf('=')) : option
|
|
355
|
+
if (spec.withValue.includes(name) && !option.includes('=')) rest = rest.slice(2)
|
|
356
|
+
else if (/^-[A-Za-z]\S+$/.test(option) && spec.withValue.includes(option.slice(0, 2))) rest = rest.slice(1)
|
|
357
|
+
else rest = rest.slice(1)
|
|
358
|
+
}
|
|
359
|
+
let positionals = spec.positionals
|
|
360
|
+
while (positionals > 0 && rest.length > 0) { rest = rest.slice(1); positionals -= 1 }
|
|
361
|
+
return rest
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// A shell invoked with a `-c` option runs its next positional as a script.
|
|
365
|
+
function shellScript(words) {
|
|
366
|
+
const rest = words.slice(1)
|
|
367
|
+
let script = false
|
|
368
|
+
for (let i = 0; i < rest.length; i += 1) {
|
|
369
|
+
const token = rest[i]
|
|
370
|
+
if (token === '--') { i += 1; if (script && rest[i] !== undefined) return rest[i]; return null }
|
|
371
|
+
if (token.startsWith('-')) {
|
|
372
|
+
if (/^-[A-Za-z]*c[A-Za-z]*$/.test(token)) script = true
|
|
373
|
+
// options that take a separate value
|
|
374
|
+
if (token === '-o' || token === '+o' || token === '-O' || token === '+O') i += 1
|
|
375
|
+
continue
|
|
376
|
+
}
|
|
377
|
+
return script ? token : null
|
|
378
|
+
}
|
|
379
|
+
return null
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// Reduces a segment's argv to the command it really runs: environment assignments and
|
|
383
|
+
// wrappers stripped, the head reduced to its basename, git and gh global options removed and
|
|
384
|
+
// git aliases declared inline expanded. `script` is set when the command is a shell running
|
|
385
|
+
// a script string, which is then classified on its own.
|
|
386
|
+
export function resolveWords(words) {
|
|
387
|
+
let rest = Array.isArray(words) ? words.filter((word) => typeof word === 'string') : []
|
|
388
|
+
let head = ''
|
|
389
|
+
for (let guard = 0; guard < 16 && rest.length > 0; guard += 1) {
|
|
390
|
+
while (rest.length > 0 && /^[A-Za-z_][A-Za-z0-9_]*=/.test(rest[0])) rest = rest.slice(1)
|
|
391
|
+
if (rest.length === 0) break
|
|
392
|
+
head = basename(rest[0])
|
|
393
|
+
if (SHELLS.has(head)) {
|
|
394
|
+
const script = shellScript(rest)
|
|
395
|
+
return { words: [head, ...rest.slice(1)], script }
|
|
396
|
+
}
|
|
397
|
+
if (!Object.prototype.hasOwnProperty.call(WRAPPERS, head)) break
|
|
398
|
+
rest = stripWrapper(rest, WRAPPERS[head])
|
|
399
|
+
head = ''
|
|
400
|
+
}
|
|
401
|
+
if (rest.length === 0) return { words: [], script: null }
|
|
402
|
+
rest = [head, ...rest.slice(1)]
|
|
403
|
+
if (head === 'git') {
|
|
404
|
+
const aliases = {}
|
|
405
|
+
let tail = rest.slice(1)
|
|
406
|
+
while (tail.length > 0 && tail[0].startsWith('-')) {
|
|
407
|
+
const option = tail[0]
|
|
408
|
+
const name = option.includes('=') ? option.slice(0, option.indexOf('=')) : option
|
|
409
|
+
const value = option.includes('=') ? option.slice(option.indexOf('=') + 1) : tail[1]
|
|
410
|
+
if (name === '-c' && typeof value === 'string') {
|
|
411
|
+
const alias = value.match(/^alias\.([^=]+)=(.*)$/)
|
|
412
|
+
if (alias) aliases[alias[1]] = alias[2]
|
|
413
|
+
}
|
|
414
|
+
if (GIT_GLOBAL_WITH_VALUE.has(name) && !option.includes('=')) tail = tail.slice(2)
|
|
415
|
+
else tail = tail.slice(1)
|
|
416
|
+
}
|
|
417
|
+
if (tail.length > 0 && Object.prototype.hasOwnProperty.call(aliases, tail[0])) {
|
|
418
|
+
const expansion = aliases[tail[0]]
|
|
419
|
+
tail = expansion.startsWith('!') ? ['!alias', ...tail.slice(1)] : [...expansion.split(/\s+/).filter(Boolean), ...tail.slice(1)]
|
|
420
|
+
}
|
|
421
|
+
rest = ['git', ...tail]
|
|
422
|
+
}
|
|
423
|
+
if (head === 'gh') {
|
|
424
|
+
let tail = rest.slice(1)
|
|
425
|
+
while (tail.length > 0 && tail[0].startsWith('-')) {
|
|
426
|
+
const option = tail[0]
|
|
427
|
+
const name = option.includes('=') ? option.slice(0, option.indexOf('=')) : option
|
|
428
|
+
if (GH_GLOBAL_WITH_VALUE.has(name) && !option.includes('=')) tail = tail.slice(2)
|
|
429
|
+
else tail = tail.slice(1)
|
|
430
|
+
}
|
|
431
|
+
rest = ['gh', ...tail]
|
|
432
|
+
}
|
|
433
|
+
return { words: rest, script: null }
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// Backwards-compatible view: the resolved argv as one string.
|
|
437
|
+
export function normalizeSegment(segment) {
|
|
438
|
+
const words = typeof segment === 'string' ? parseCommand(segment).flatMap((entry) => entry.words) : []
|
|
439
|
+
return resolveWords(words).words.join(' ')
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// --- classification ------------------------------------------------------------------
|
|
443
|
+
|
|
444
|
+
function ask(reason, rule) {
|
|
445
|
+
return { decision: 'ask', reason: reason + ' — run it by hand', rule }
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
const ALLOW = { decision: 'allow', reason: null, rule: 'not-guarded' }
|
|
449
|
+
|
|
450
|
+
const PUSH_WITH_VALUE = new Set(['-o', '--push-option', '--receive-pack', '--exec', '--repo'])
|
|
451
|
+
|
|
452
|
+
// `git push` read as git reads it: the flags, and the positionals [remote, refspec...].
|
|
453
|
+
function pushArguments(words) {
|
|
454
|
+
const flags = []
|
|
455
|
+
const positionals = []
|
|
456
|
+
let rest = words.slice(2)
|
|
457
|
+
while (rest.length > 0) {
|
|
458
|
+
const token = rest[0]
|
|
459
|
+
if (token === '--') { positionals.push(...rest.slice(1)); break }
|
|
460
|
+
if (token.startsWith('-') && token !== '-') {
|
|
461
|
+
const name = token.includes('=') ? token.slice(0, token.indexOf('=')) : token
|
|
462
|
+
flags.push(name)
|
|
463
|
+
rest = PUSH_WITH_VALUE.has(name) && !token.includes('=') ? rest.slice(2) : rest.slice(1)
|
|
464
|
+
continue
|
|
465
|
+
}
|
|
466
|
+
positionals.push(token)
|
|
467
|
+
rest = rest.slice(1)
|
|
468
|
+
}
|
|
469
|
+
return { flags, positionals }
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function shortFlagsInclude(flags, letter) {
|
|
473
|
+
return flags.some((flag) => /^-[A-Za-z]+$/.test(flag) && flag.includes(letter))
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// The branch a refspec lands on, or a marker for what the guard cannot read.
|
|
477
|
+
function pushDestination(refspec) {
|
|
478
|
+
const spec = refspec.startsWith('+') ? refspec.slice(1) : refspec
|
|
479
|
+
const colon = spec.indexOf(':')
|
|
480
|
+
const source = colon === -1 ? spec : spec.slice(0, colon)
|
|
481
|
+
const destination = colon === -1 ? spec : spec.slice(colon + 1)
|
|
482
|
+
if (colon !== -1 && source === '') return { kind: KIND_DELETE, branch: destination.replace(/^refs\/heads\//, '') }
|
|
483
|
+
if (destination.startsWith('refs/tags/')) return { kind: 'tag', branch: destination }
|
|
484
|
+
const branch = destination.replace(/^refs\/heads\//, '')
|
|
485
|
+
if (branch === '' || branch === 'HEAD' || branch === '@' || /[~^]/.test(branch) || branch.startsWith('refs/')) return { kind: 'unreadable', branch }
|
|
486
|
+
return { kind: 'branch', branch }
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// The verbs an interpreter string is probed for: text a shell would run ends up in a nested
|
|
490
|
+
// segment and is classified properly; text handed to node, python, ssh or a file only gets
|
|
491
|
+
// this probe, and a hit asks.
|
|
492
|
+
const FAMILY_VERBS = ['git push', 'gh pr merge', 'gh api', 'gh release', 'npm publish', 'pnpm publish', 'yarn publish', 'bun publish', 'git tag', 'git reset', 'git branch -D', 'release create', VERB_WORKTREE_REMOVE]
|
|
493
|
+
|
|
494
|
+
function familyProbe(text, environments) {
|
|
495
|
+
const verbs = [...FAMILY_VERBS, ...environments.map((entry) => entry.pattern.split(/\s+/).slice(0, 2).join(' ')).filter((verb) => verb.includes(' '))]
|
|
496
|
+
return verbs.find((verb) => {
|
|
497
|
+
const index = text.indexOf(verb)
|
|
498
|
+
if (index === -1) return false
|
|
499
|
+
const before = index === 0 ? '' : text[index - 1]
|
|
500
|
+
const after = text[index + verb.length] ?? ''
|
|
501
|
+
return !/[\w-]/.test(before) && !/[\w-]/.test(after)
|
|
502
|
+
}) || null
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function touchesPolicyStore(pieces) {
|
|
506
|
+
return pieces.some((piece) => typeof piece === 'string' && piece.includes(POLICY_STORE))
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
function classifyResolved(segment, resolved, settings, shipRule) {
|
|
510
|
+
const words = resolved.words
|
|
511
|
+
const text = words.join(' ')
|
|
512
|
+
const asWritten = [segment.words[0] ?? '', ...words.slice(1)].join(' ')
|
|
513
|
+
if (words.length === 0) return ALLOW
|
|
514
|
+
|
|
515
|
+
// Always-ask: the flag may sit anywhere.
|
|
516
|
+
if (words.includes('--no-verify')) return ask("skipping the pre-commit checks needs the operator's word", 'always-ask')
|
|
517
|
+
if (words[0] === 'git') {
|
|
518
|
+
const sub = words[1]
|
|
519
|
+
if (sub === 'push') {
|
|
520
|
+
const { flags, positionals } = pushArguments(words)
|
|
521
|
+
const refspecs = positionals.slice(1)
|
|
522
|
+
const forced = flags.includes('--force') || shortFlagsInclude(flags, 'f') || refspecs.some((spec) => spec.startsWith('+'))
|
|
523
|
+
if (forced) return ask("a force push needs the operator's word", 'always-ask')
|
|
524
|
+
const destinations = refspecs.map(pushDestination)
|
|
525
|
+
if (flags.includes('--delete') || shortFlagsInclude(flags, 'd') || destinations.some((entry) => entry.kind === 'delete')) {
|
|
526
|
+
return ask("deleting a remote branch needs the operator's word", 'always-ask')
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
if (sub === 'reset' && words.includes('--hard')) return ask("a hard reset needs the operator's word", 'always-ask')
|
|
530
|
+
if (sub === 'branch') {
|
|
531
|
+
const flags = words.slice(2).filter((word) => word.startsWith('-'))
|
|
532
|
+
if (flags.includes('--delete') || shortFlagsInclude(flags, 'd') || shortFlagsInclude(flags, 'D')) return ask("deleting a branch needs the operator's word", 'always-ask')
|
|
533
|
+
}
|
|
534
|
+
if (sub === 'worktree' && words[2] === 'remove') {
|
|
535
|
+
const flags = words.slice(3).filter((word) => word.startsWith('-'))
|
|
536
|
+
if (flags.includes('--force') || shortFlagsInclude(flags, 'f')) return ask("removing a worktree by force needs the operator's word", 'always-ask')
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// Environments: longest pattern first, so `--env production` beats a shorter `wrangler deploy` line.
|
|
541
|
+
const matches = settings.environments
|
|
542
|
+
.filter((entry) => text.startsWith(entry.pattern) || asWritten.startsWith(entry.pattern))
|
|
543
|
+
.sort((a, b) => b.pattern.length - a.pattern.length)
|
|
544
|
+
if (matches.length > 0) {
|
|
545
|
+
const winner = matches[0]
|
|
546
|
+
if (winner.policy === 'auto') return { decision: 'allow', reason: null, rule: 'environment:' + winner.target }
|
|
547
|
+
return ask('the ' + winner.target + " environment needs the operator's word", 'environment:' + winner.target)
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
// The default-branch operations.
|
|
551
|
+
if (words[0] === 'gh') {
|
|
552
|
+
if (words[1] === 'pr' && words[2] === 'merge') return ask("merging to the default branch needs the operator's word", shipRule)
|
|
553
|
+
if (words[1] === 'api' && words.some((word) => /pulls\/\d+\/merge(\/|$)/.test(word))) return ask("merging to the default branch needs the operator's word", shipRule)
|
|
554
|
+
}
|
|
555
|
+
if (words[0] === 'git') {
|
|
556
|
+
const sub = words[1]
|
|
557
|
+
if (sub === 'tag') return ask("tagging a release needs the operator's word", shipRule)
|
|
558
|
+
if (sub === 'push') {
|
|
559
|
+
const { flags, positionals } = pushArguments(words)
|
|
560
|
+
if (flags.includes('--all') || flags.includes('--mirror')) return ask(`pushing every branch reaches ${settings.defaultBranch ?? 'the default branch'}, which needs the operator's word`, shipRule)
|
|
561
|
+
if (flags.includes('--tags')) return ask("pushing tags publishes a release, which needs the operator's word", shipRule)
|
|
562
|
+
const destinations = positionals.slice(1).map(pushDestination)
|
|
563
|
+
if (destinations.length === 0) return ask("a push whose branch the guard cannot read needs the operator's word", 'unclassified-in-family')
|
|
564
|
+
if (destinations.some((entry) => entry.kind === 'tag')) return ask("pushing a tag publishes a release, which needs the operator's word", shipRule)
|
|
565
|
+
if (destinations.some((entry) => entry.kind === 'unreadable')) return ask("a push whose branch the guard cannot read needs the operator's word", 'unclassified-in-family')
|
|
566
|
+
if (settings.defaultBranch === null) return ask("a push whose target the guard cannot check needs the operator's word", 'unclassified-in-family')
|
|
567
|
+
if (destinations.some((entry) => entry.branch === settings.defaultBranch)) return ask(`pushing to ${settings.defaultBranch} needs the operator's word`, shipRule)
|
|
568
|
+
return { decision: 'allow', reason: null, rule: 'branch-push' }
|
|
569
|
+
}
|
|
570
|
+
if (sub !== undefined && !sub.startsWith('-') && !GIT_SUBCOMMANDS.has(sub)) {
|
|
571
|
+
return ask(`git ${sub} is not a subcommand the guard knows — an alias it cannot see through needs the operator's word`, 'unclassified-in-family')
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
if ((words[0] === 'npm' || words[0] === 'pnpm' || words[0] === 'bun') && words[1] === 'publish') return ask("publishing needs the operator's word", shipRule)
|
|
575
|
+
for (const pattern of settings.shipAsk) {
|
|
576
|
+
if (text.startsWith(pattern) || asWritten.startsWith(pattern)) return ask('a Ship runbook ask: step needs the operator\'s word', shipRule)
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// Fail closed: it looks like shipping, and no line in the policy says it is safe.
|
|
580
|
+
if (words.some((word) => /^(deploy|publish)(:|$)/.test(word)) || words.some((word, index) => word === 'release' && words[index + 1] === 'create')) {
|
|
581
|
+
return ask('no ## Environments line in dev.md covers this deploy, so it needs the operator\'s word', 'unclassified-in-family')
|
|
582
|
+
}
|
|
583
|
+
return ALLOW
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
export function classifySegment(segment, policy) {
|
|
587
|
+
const entry = typeof segment === 'string'
|
|
588
|
+
? (parseCommand(segment)[0] ?? { words: [], redirects: [], strings: [] })
|
|
589
|
+
: Array.isArray(segment) ? { words: segment, redirects: [], strings: [] } : segment
|
|
590
|
+
if (!entry || !Array.isArray(entry.words) || entry.words.length === 0) return ALLOW
|
|
591
|
+
const missing = policy && typeof policy === 'object' && typeof policy.missing === 'string' ? policy.missing : null
|
|
592
|
+
const settings = missing ? NO_POLICY : (policy && typeof policy === 'object' ? { ...NO_POLICY, defaultBranch: 'main', ...policy } : { ...NO_POLICY, defaultBranch: 'main' })
|
|
593
|
+
const shipRule = settings.gates === 1 ? 'default-branch-ship' : 'default-branch'
|
|
594
|
+
|
|
595
|
+
if (touchesPolicyStore([...entry.words, ...(entry.redirects ?? [])])) return ask("editing the ship-guard policy store needs the operator's word", 'always-ask')
|
|
596
|
+
|
|
597
|
+
const resolved = resolveWords(entry.words)
|
|
598
|
+
let result
|
|
599
|
+
if (resolved.script !== null) {
|
|
600
|
+
result = classifyCommand(resolved.script, policy)
|
|
601
|
+
} else {
|
|
602
|
+
result = classifyResolved(entry, resolved, settings, shipRule)
|
|
603
|
+
if (result.decision !== 'ask' && !(resolved.words[0] === 'git' && resolved.words[1] === 'commit')) {
|
|
604
|
+
for (const string of entry.strings ?? []) {
|
|
605
|
+
const verb = familyProbe(string, settings.environments)
|
|
606
|
+
if (verb) {
|
|
607
|
+
result = ask(`text handed to another program carries \`${verb}\`, which the guard cannot classify, so it needs the operator's word`, 'unclassified-in-family')
|
|
608
|
+
break
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
if (missing && result.decision === 'ask' && result.rule !== 'always-ask' && result.rule !== 'no-policy') {
|
|
614
|
+
return { decision: 'ask', reason: missing + ' — run `' + SYNC_COMMAND + '` — ' + result.reason, rule: 'no-policy' }
|
|
615
|
+
}
|
|
616
|
+
return result
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
export function classifyCommand(command, policy) {
|
|
620
|
+
if (typeof command !== 'string') return ALLOW
|
|
621
|
+
if (command.includes(POLICY_STORE)) return ask("editing the ship-guard policy store needs the operator's word", 'always-ask')
|
|
622
|
+
let allowed = ALLOW
|
|
623
|
+
for (const segment of parseCommand(command)) {
|
|
624
|
+
const result = classifySegment(segment, policy)
|
|
625
|
+
if (result.decision === 'ask') return result
|
|
626
|
+
if (allowed.rule === 'not-guarded' && result.rule !== 'not-guarded') allowed = result
|
|
627
|
+
}
|
|
628
|
+
return allowed
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
// --- harness I/O ---------------------------------------------------------------------
|
|
632
|
+
|
|
633
|
+
const UNREADABLE = {
|
|
634
|
+
decision: 'ask',
|
|
635
|
+
reason: 'the ship guard could not read the hook payload — run the command by hand',
|
|
636
|
+
rule: 'unreadable-payload',
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
function shellQuote(part) {
|
|
640
|
+
if (/^[A-Za-z0-9_\/.:=@%+,-]+$/.test(part)) return part
|
|
641
|
+
return "'" + part.replace(/'/g, "'\\''") + "'"
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
// Claude puts the shell command at tool_input.command; Codex may send the argv array or the
|
|
645
|
+
// string itself. An argv array is quoted back into one shell string so the parser reads each
|
|
646
|
+
// element as one word. Every other shape is a tool that runs no shell command.
|
|
647
|
+
export function extractCommand(payload) {
|
|
648
|
+
const input = payload && typeof payload === 'object' ? payload.tool_input : null
|
|
649
|
+
if (typeof input === 'string') return input
|
|
650
|
+
if (!input || typeof input !== 'object') return null
|
|
651
|
+
const command = input.command
|
|
652
|
+
if (typeof command === 'string') return command
|
|
653
|
+
if (Array.isArray(command) && command.every((part) => typeof part === 'string')) return command.map(shellQuote).join(' ')
|
|
654
|
+
return null
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
export function renderDecision(result, harness) {
|
|
658
|
+
if (harness !== 'claude' && harness !== 'codex') {
|
|
659
|
+
return JSON.stringify({
|
|
660
|
+
decision: 'block',
|
|
661
|
+
reason: 'the ship guard is wired without --harness claude|codex — fix the hook command',
|
|
662
|
+
})
|
|
663
|
+
}
|
|
664
|
+
if (!result || result.decision !== 'ask') return ''
|
|
665
|
+
if (harness === 'claude') {
|
|
666
|
+
return JSON.stringify({
|
|
667
|
+
hookSpecificOutput: {
|
|
668
|
+
hookEventName: 'PreToolUse',
|
|
669
|
+
permissionDecision: 'ask',
|
|
670
|
+
permissionDecisionReason: result.reason,
|
|
671
|
+
},
|
|
672
|
+
})
|
|
673
|
+
}
|
|
674
|
+
return JSON.stringify({ decision: 'block', reason: result.reason })
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
function flag(argv, name) {
|
|
678
|
+
const index = argv.indexOf(name)
|
|
679
|
+
if (index === -1 || index === argv.length - 1) return null
|
|
680
|
+
return argv[index + 1]
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
function originRemote(cwd) {
|
|
684
|
+
try {
|
|
685
|
+
return execFileSync('git', ['remote', 'get-url', 'origin'], { cwd, encoding: 'utf8', stdio: [NO_STDIO, 'pipe', NO_STDIO], timeout: 5000 }).trim()
|
|
686
|
+
} catch {
|
|
687
|
+
return null
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
function readIfPresent(path) {
|
|
692
|
+
try {
|
|
693
|
+
return readFileSync(path, 'utf8')
|
|
694
|
+
} catch {
|
|
695
|
+
return null
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
// The policy for this invocation: `--repo` and `--policy` override the origin lookup and the
|
|
700
|
+
// home-directory path (for --check runs and tests); the hook wiring passes neither.
|
|
701
|
+
export function loadPolicy(argv, { cwd, home }) {
|
|
702
|
+
const repo = flag(argv, '--repo') || repoFromRemote(originRemote(cwd))
|
|
703
|
+
const explicit = flag(argv, '--policy')
|
|
704
|
+
const path = explicit || (repo ? policyPath(home, repo) : null)
|
|
705
|
+
const shown = explicit || (repo ? '~/' + POLICY_STORE + '/' + repoSegment(repo) + '.json' : null)
|
|
706
|
+
return readPolicyFile(path ? readIfPresent(path) : null, repo, shown)
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
function main(argv) {
|
|
710
|
+
const policy = loadPolicy(argv, { cwd: process.cwd(), home: homedir() })
|
|
711
|
+
|
|
712
|
+
if (argv.includes('--check')) {
|
|
713
|
+
const command = flag(argv, '--command') || ''
|
|
714
|
+
const result = classifyCommand(command, policy)
|
|
715
|
+
if (argv.includes('--json')) process.stdout.write(JSON.stringify(result) + '\n')
|
|
716
|
+
else if (result.reason) process.stdout.write(result.reason + '\n')
|
|
717
|
+
process.exit(result.decision === 'ask' ? 2 : 0)
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
const harness = flag(argv, '--harness')
|
|
721
|
+
let payload = null
|
|
722
|
+
try {
|
|
723
|
+
payload = JSON.parse(readFileSync(0, 'utf8'))
|
|
724
|
+
} catch {
|
|
725
|
+
process.stdout.write(renderDecision(UNREADABLE, harness))
|
|
726
|
+
process.exit(0)
|
|
727
|
+
}
|
|
728
|
+
const command = extractCommand(payload)
|
|
729
|
+
if (command === null) {
|
|
730
|
+
// Not a shell tool call at all — nothing for this guard to say.
|
|
731
|
+
if (harness !== 'claude' && harness !== 'codex') process.stdout.write(renderDecision(UNREADABLE, harness))
|
|
732
|
+
process.exit(0)
|
|
733
|
+
}
|
|
734
|
+
process.stdout.write(renderDecision(classifyCommand(command, policy), harness))
|
|
735
|
+
process.exit(0)
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) main(process.argv.slice(2))
|