@codedology/code-forge 0.1.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/CHANGELOG.md +64 -0
- package/LICENSE +21 -0
- package/README.md +127 -0
- package/bin/code-forge.mjs +145 -0
- package/docs/README.md +37 -0
- package/docs/reference/blocks.json +288 -0
- package/docs/reference/config.md +162 -0
- package/examples/first-use/README.md +11 -0
- package/examples/first-use/clamp.acceptance.yml +8 -0
- package/examples/first-use/clamp.brief.md +16 -0
- package/examples/first-use/coder/src/math.mjs +31 -0
- package/examples/first-use/coder/test/clamp.test.mjs +26 -0
- package/examples/node-lib/.env.example +3 -0
- package/examples/node-lib/.gitattributes +3 -0
- package/examples/node-lib/.github/workflows/test.yml +11 -0
- package/examples/node-lib/CHANGELOG.md +13 -0
- package/examples/node-lib/README.md +17 -0
- package/examples/node-lib/gitignore +3 -0
- package/examples/node-lib/package.json +19 -0
- package/examples/node-lib/src/list.mjs +23 -0
- package/examples/node-lib/src/math.mjs +21 -0
- package/examples/node-lib/src/strings.mjs +34 -0
- package/examples/node-lib/test/list.test.mjs +12 -0
- package/examples/node-lib/test/math.test.mjs +13 -0
- package/examples/node-lib/test/package.test.mjs +18 -0
- package/examples/node-lib/test/strings.test.mjs +28 -0
- package/package.json +52 -0
- package/schema/code-forge.schema.json +331 -0
- package/schema/facts.schema.json +28 -0
- package/scripts/first-use.mjs +517 -0
- package/scripts/gen-config-doc.mjs +241 -0
- package/skill/SKILL.md +79 -0
- package/skill/references/adapters/claude-code.md +19 -0
- package/skill/references/adapters/solo.md +34 -0
- package/skill/references/adapters/subprocess.md +15 -0
- package/skill/references/code.md +64 -0
- package/skill/references/continuity.md +31 -0
- package/skill/references/decisions.md +50 -0
- package/skill/references/degraded.md +40 -0
- package/skill/references/harden.md +26 -0
- package/skill/references/ledger.md +23 -0
- package/skill/references/plan.md +43 -0
- package/skill/references/proof.md +34 -0
- package/skill/references/review.md +42 -0
- package/skill/references/rules-core.md +72 -0
- package/skill/references/security.md +29 -0
- package/skill/scripts/forge +40 -0
- package/skill/templates/block-record.md +19 -0
- package/skill/templates/facts-sheet.md +22 -0
- package/skill/templates/plan.md +53 -0
- package/src/cli/author.mjs +87 -0
- package/src/cli/block.mjs +262 -0
- package/src/cli/doctor.mjs +74 -0
- package/src/cli/facts.mjs +124 -0
- package/src/cli/gates.mjs +280 -0
- package/src/cli/help.mjs +48 -0
- package/src/cli/init.mjs +14 -0
- package/src/cli/jev.mjs +175 -0
- package/src/cli/keys.mjs +170 -0
- package/src/cli/ledger.mjs +160 -0
- package/src/cli/list.mjs +68 -0
- package/src/cli/models.mjs +131 -0
- package/src/cli/plan.mjs +70 -0
- package/src/cli/proof.mjs +316 -0
- package/src/cli/remove.mjs +76 -0
- package/src/cli/report.mjs +73 -0
- package/src/cli/resolve.mjs +79 -0
- package/src/cli/review-file.mjs +152 -0
- package/src/cli/run.mjs +159 -0
- package/src/cli/s2.mjs +93 -0
- package/src/cli/spawn.mjs +104 -0
- package/src/cli/upgrade.mjs +103 -0
- package/src/cli/validate.mjs +108 -0
- package/src/cli/version.mjs +18 -0
- package/src/cli/worker.mjs +76 -0
- package/src/config/defaults/anthropic.mjs +18 -0
- package/src/config/defaults/index.mjs +24 -0
- package/src/config/defaults/openai.mjs +16 -0
- package/src/config/defaults/xai.mjs +18 -0
- package/src/config/known-ids.mjs +171 -0
- package/src/config/load.mjs +135 -0
- package/src/config/migrate.mjs +46 -0
- package/src/config/refresh.mjs +276 -0
- package/src/config/schema-compile.mjs +211 -0
- package/src/config/schema-paths.mjs +84 -0
- package/src/config/secret-patterns.mjs +85 -0
- package/src/config/validate.mjs +694 -0
- package/src/decide/author-loop.mjs +90 -0
- package/src/decide/escalation.mjs +262 -0
- package/src/decide/fallback-rules.mjs +172 -0
- package/src/decide/jev-client.mjs +216 -0
- package/src/decide/questions.mjs +161 -0
- package/src/decide/thresholds.mjs +199 -0
- package/src/doctor/index.mjs +105 -0
- package/src/doctor/local.mjs +162 -0
- package/src/doctor/probes.mjs +358 -0
- package/src/doctor/rows.mjs +51 -0
- package/src/doctor/worker-probe.mjs +125 -0
- package/src/engines/alias-map.mjs +78 -0
- package/src/engines/builders/claude.mjs +224 -0
- package/src/engines/builders/codex.mjs +242 -0
- package/src/engines/builders/grok.mjs +132 -0
- package/src/engines/builders/index.mjs +35 -0
- package/src/engines/builders/validate-params.mjs +34 -0
- package/src/engines/codex-home.mjs +230 -0
- package/src/engines/isolation.mjs +84 -0
- package/src/engines/probe.mjs +116 -0
- package/src/engines/provider-cli.mjs +24 -0
- package/src/engines/render-rules.mjs +41 -0
- package/src/engines/sentinel.mjs +133 -0
- package/src/engines/usage-parse.mjs +149 -0
- package/src/gates/acceptance.mjs +95 -0
- package/src/gates/detect.mjs +187 -0
- package/src/gates/invariants.mjs +43 -0
- package/src/gates/run.mjs +72 -0
- package/src/gates/safe-edit.mjs +266 -0
- package/src/gates/scope.mjs +154 -0
- package/src/gates/secret-scan.mjs +79 -0
- package/src/gates/transcript-grep.mjs +27 -0
- package/src/install/agent-env.mjs +76 -0
- package/src/install/detect.mjs +121 -0
- package/src/install/harnesses.mjs +131 -0
- package/src/install/link.mjs +463 -0
- package/src/install/wizard/answers.mjs +389 -0
- package/src/install/wizard/flags.mjs +248 -0
- package/src/install/wizard/project-file.mjs +128 -0
- package/src/install/wizard/run.mjs +366 -0
- package/src/install/wizard/steps.mjs +201 -0
- package/src/keys/backends/constants.mjs +7 -0
- package/src/keys/backends/file.mjs +99 -0
- package/src/keys/backends/keychain.mjs +81 -0
- package/src/keys/backends/security-cli.mjs +105 -0
- package/src/keys/onepassword.mjs +46 -0
- package/src/keys/store.mjs +347 -0
- package/src/ledger/calibration.mjs +109 -0
- package/src/ledger/export.mjs +52 -0
- package/src/ledger/outcome.mjs +397 -0
- package/src/ledger/paths.mjs +30 -0
- package/src/ledger/prices.mjs +49 -0
- package/src/ledger/report.mjs +250 -0
- package/src/ledger/write.mjs +279 -0
- package/src/proof/export.mjs +358 -0
- package/src/proof/lock.mjs +138 -0
- package/src/proof/red-green.mjs +519 -0
- package/src/proof/red-parse.mjs +103 -0
- package/src/proof/tiers.mjs +62 -0
- package/src/review/budget.mjs +241 -0
- package/src/review/context.mjs +156 -0
- package/src/review/engine.mjs +264 -0
- package/src/review/finding.schema.json +47 -0
- package/src/review/fixloop.mjs +467 -0
- package/src/review/gate-check.mjs +249 -0
- package/src/review/lenses/A.md +22 -0
- package/src/review/lenses/B.md +23 -0
- package/src/review/lenses/full.md +23 -0
- package/src/review/lenses/judge.md +21 -0
- package/src/review/lenses/quick.md +21 -0
- package/src/review/lenses/recheck.md +23 -0
- package/src/review/packet.mjs +355 -0
- package/src/review/triage.mjs +149 -0
- package/src/review/validate-review.mjs +70 -0
- package/src/session/author.mjs +228 -0
- package/src/session/facts.mjs +855 -0
- package/src/session/plan-check.mjs +294 -0
- package/src/session/s2.mjs +164 -0
- package/src/session/spawn.mjs +610 -0
- package/src/state/block.mjs +289 -0
- package/src/state/cli-args.mjs +67 -0
- package/src/state/paths.mjs +92 -0
- package/src/state/registry.mjs +189 -0
- package/src/state/run.mjs +266 -0
- package/src/state/signer.mjs +132 -0
- package/src/util/exec.mjs +315 -0
- package/src/util/forbidden.mjs +787 -0
- package/src/util/git.mjs +381 -0
- package/src/util/log.mjs +100 -0
- package/src/util/reaper.mjs +300 -0
- package/src/util/redact.mjs +212 -0
- package/src/util/tmp.mjs +409 -0
- package/src/worker/engine.mjs +338 -0
- package/src/worker/loop.mjs +392 -0
- package/src/worker/queue.mjs +186 -0
- package/src/worker/review-state.mjs +145 -0
- package/src/worker/ticket.mjs +195 -0
|
@@ -0,0 +1,787 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one forbidden-command list (plan §8.4), its structural matcher, and its three per-CLI
|
|
3
|
+
* renderers.
|
|
4
|
+
*
|
|
5
|
+
* ## What this is — and what it is not
|
|
6
|
+
*
|
|
7
|
+
* This is a DENYLIST, and a denylist can never be complete: a shell can spell any command in
|
|
8
|
+
* endless ways (`sh -c '…'`, `xargs rm`, `find -delete`, a git alias defined with `git config`,
|
|
9
|
+
* `sudo`/`env`/`nice` wrappers, a script file the coder writes and then runs, …). It is ONE
|
|
10
|
+
* layer of three, and must never be described or relied on as the only one:
|
|
11
|
+
*
|
|
12
|
+
* 1. The sandbox the coder process runs in (working-directory confinement, `--restricted`
|
|
13
|
+
* file tools, no production credentials in the environment) — the layer that actually
|
|
14
|
+
* bounds what a command can reach.
|
|
15
|
+
* 2. This list — rendered into each CLI's own deny flag (Claude `--disallowedTools`, Grok
|
|
16
|
+
* `--deny`, Codex execpolicy rules) and checked by `isForbidden()` before the package itself
|
|
17
|
+
* spawns anything on a coder's behalf.
|
|
18
|
+
* 3. The block gates (B5: safe-edit, scope, transcript grep) — which judge the RESULT (a
|
|
19
|
+
* deleted test file, a rewritten history, a forbidden verb in the transcript) regardless of
|
|
20
|
+
* how the command was spelled.
|
|
21
|
+
*
|
|
22
|
+
* ## `isForbidden()` — the structural matcher
|
|
23
|
+
*
|
|
24
|
+
* It normalizes argv before matching, so the common respellings of a listed command are caught:
|
|
25
|
+
* - `argv[0]` is compared by basename (`/usr/bin/git`, `/bin/rm`, `git.exe` → `git`, `rm`).
|
|
26
|
+
* - git's global options before the subcommand are skipped (`git -C . push --force`,
|
|
27
|
+
* `git -c k=v reset --hard`, `git --git-dir=.git clean -fd`, `git --work-tree . rm x`).
|
|
28
|
+
* An UNKNOWN option there is tried both as a lone flag and as a flag taking the next token as
|
|
29
|
+
* its value, and the argv is forbidden if either reading matches — fail closed.
|
|
30
|
+
* - flags are matched anywhere after the command (`git push origin main --force`), in `--flag`,
|
|
31
|
+
* `--flag=value` (`--force-with-lease=main:abc`) and combined short-cluster (`-uf`, `-Rf`,
|
|
32
|
+
* `-rfv`) spellings, plus `+refspec` pushes (`git push origin +main`).
|
|
33
|
+
* - `contains` tokens match as a SUBSTRING of any argv token (`--db=prod_main`,
|
|
34
|
+
* `postgres://prod-host/db`); they do not match across two tokens (`--env production`).
|
|
35
|
+
* Known limits, beyond the unbounded respellings listed above: it sees only argv the package
|
|
36
|
+
* itself is about to run — never the commands a coder runs inside its own Bash tool (layers 1
|
|
37
|
+
* and 3 cover those); git aliases and `-c alias.*` definitions are not expanded.
|
|
38
|
+
*
|
|
39
|
+
* ## Entry kinds
|
|
40
|
+
*
|
|
41
|
+
* - `prefix` — the normalized argv's first N tokens equal `tokens` (e.g. `git clean`).
|
|
42
|
+
* - `commandFlag` — the normalized argv starts with `command`, AND for EVERY group in `groups`
|
|
43
|
+
* at least one later token matches that group: equal to a member of `anyOf`, starting with a
|
|
44
|
+
* member of `anyPrefixOf`, or a single-dash short-option cluster (`-rfv`) containing one of
|
|
45
|
+
* `shortLetters`. One group = "any of these flags"; two groups = "one of each" (e.g. `git
|
|
46
|
+
* branch --delete` AND `--force`).
|
|
47
|
+
* - `contains` — every member of `tokens` is a substring of some argv token (a configured
|
|
48
|
+
* `production.markers` / `production.names` value; `mergeForbidden()` adds the real ones).
|
|
49
|
+
* - `anyPrefix` (B8, C18) — the normalized argv starts with ANY of `prefixes`, after launchers
|
|
50
|
+
* are unwrapped (`npx`/`bunx` with their options, `-p/--package <pkg>` included; `pnpm
|
|
51
|
+
* exec|dlx`; `yarn dlx`; `node [options] <file>`), a `code-forge.mjs` / `@codedology/code-forge[@x]`
|
|
52
|
+
* token read as `code-forge` (a repeated bin name after the package is dropped), and option
|
|
53
|
+
* tokens between the words skipped (`code-forge --verbose worker`).
|
|
54
|
+
* - `path` (B8, C18) — protects the signer's files (plan §8.6). `paths` are `~/…` (the user's
|
|
55
|
+
* home, matched as `~`, `$HOME`, `${HOME}` or the real `os.homedir()` at call time) or
|
|
56
|
+
* project-relative (matched at token start or after `/`, `=`, `:`, whitespace or a quote — so
|
|
57
|
+
* a path inside a `bash -c "…"` / `node -e "…"` script token is seen too). `access:
|
|
58
|
+
* 'readwrite'` forbids ANY argv naming the path; `access: 'write'` forbids it only when the
|
|
59
|
+
* command — after the wrappers `sudo`/`env [VAR=x…]`/`xargs`/`nice`/`timeout <n>`/`command`
|
|
60
|
+
* — is a write verb (`WRITE_VERBS`; `sed`/`perl` with `-i…`, `--in-place[=…]`; `git
|
|
61
|
+
* mv|rm|restore|checkout`). A `*` in a path matches within one segment.
|
|
62
|
+
* Known limits: a relative spelling from inside `$HOME` (`cd ~; cat .code-forge/runs/x.key`),
|
|
63
|
+
* a shell redirection (not argv), and a WRITE hidden inside a script string (`bash -c "cp x
|
|
64
|
+
* .code-forge/reviews/y"` — the verb is `bash`) are not seen here; `scanTranscript()` and the
|
|
65
|
+
* block gate are the detectors for those, and the signer is tamper EVIDENCE, not a same-user
|
|
66
|
+
* boundary (§8.6).
|
|
67
|
+
*
|
|
68
|
+
* ## Renderers — what the CLIs can and cannot enforce
|
|
69
|
+
*
|
|
70
|
+
* The pinned CLIs' deny rules match a command PREFIX (Claude `Bash(<prefix>:*)`, documented in
|
|
71
|
+
* `test/fixtures/help/claude-2.1.282.txt`; Grok's `--deny` is its documented alias; Codex's
|
|
72
|
+
* execpolicy matches a token-array prefix). So each renderer emits, per entry, one concrete rule
|
|
73
|
+
* per flag spelling in the position right after the command (`Bash(git push --force:*)`,
|
|
74
|
+
* `Bash(git push -f:*)`, `Bash(rm -rf:*)`, …). That blocks the common spellings at the CLI layer;
|
|
75
|
+
* a flag placed after other arguments (`git push origin main --force`), a global option before
|
|
76
|
+
* the subcommand, or a `+refspec` escapes a prefix rule — those are caught by `isForbidden()`
|
|
77
|
+
* (for argv the package runs) and by the gates (layer 3). An entry is `enforced: false` only when
|
|
78
|
+
* the CLI syntax cannot express it at all: the `contains` kind everywhere (no pinned CLI
|
|
79
|
+
* documents a match-anywhere rule), and the `path` kind for Codex. For Codex the `path` entries
|
|
80
|
+
* are NOT ENFORCED AT ALL: execpolicy matches command prefixes, not file paths, and the
|
|
81
|
+
* `workspace-write` sandbox neither stops reads of `~/.code-forge/runs/` (it limits writes, not
|
|
82
|
+
* reads) nor writes to `.code-forge/reviews/` or `.code-forge/queue/*.done` (they are INSIDE the
|
|
83
|
+
* workspace). On Codex the prose list, `scanTranscript()` and the block gate are the only
|
|
84
|
+
* detectors. For Claude — and Grok, whose renderer IS `renderForClaude` (`--deny` is the
|
|
85
|
+
* documented alias of `--disallowedTools`) — a `path` entry renders as file-tool rules
|
|
86
|
+
* `Read(<glob>)` / `Edit(<glob>)` / `Write(<glob>)` (`Read` only for `readwrite`), `~/` kept as
|
|
87
|
+
* the home prefix and each directory widened to `<dir>/**`. Whether the pinned Claude or Grok
|
|
88
|
+
* honours path-scoped rules is unproven (plan [A22], §0.6.7: `doctor` probes it);
|
|
89
|
+
* `scanTranscript()` is the detector either way.
|
|
90
|
+
*/
|
|
91
|
+
|
|
92
|
+
import os from 'node:os';
|
|
93
|
+
import path from 'node:path';
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* @typedef {object} FlagGroup
|
|
97
|
+
* @property {ReadonlyArray<string>} [anyOf] - exact-token flags, any one.
|
|
98
|
+
* @property {ReadonlyArray<string>} [anyPrefixOf] - token prefixes, any one (`--force-with-lease=`, `+`).
|
|
99
|
+
* @property {ReadonlyArray<string>} [shortLetters] - letters matched inside a `-xyz` short-option cluster.
|
|
100
|
+
*/
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* @typedef {object} ForbiddenEntry
|
|
104
|
+
* @property {string} id
|
|
105
|
+
* @property {"prefix"|"contains"|"commandFlag"|"anyPrefix"|"path"} kind
|
|
106
|
+
* @property {ReadonlyArray<string>} [tokens] - `prefix` and `contains` kinds.
|
|
107
|
+
* @property {ReadonlyArray<string>} [command] - `commandFlag` kind: the required argv prefix.
|
|
108
|
+
* @property {ReadonlyArray<FlagGroup>} [groups] - `commandFlag` kind: every group must match.
|
|
109
|
+
* @property {ReadonlyArray<ReadonlyArray<string>>} [prefixes] - `anyPrefix` kind.
|
|
110
|
+
* @property {ReadonlyArray<string>} [paths] - `path` kind: `~/…` or project-relative.
|
|
111
|
+
* @property {"readwrite"|"write"} [access] - `path` kind.
|
|
112
|
+
* @property {string} description
|
|
113
|
+
*/
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* @param {FlagGroup} group
|
|
117
|
+
* @returns {FlagGroup}
|
|
118
|
+
*/
|
|
119
|
+
function freezeGroup(group) {
|
|
120
|
+
/** @type {Record<string, unknown>} */
|
|
121
|
+
const frozen = {};
|
|
122
|
+
for (const [key, value] of Object.entries(group)) {
|
|
123
|
+
frozen[key] = Object.freeze([...value]);
|
|
124
|
+
}
|
|
125
|
+
return Object.freeze(/** @type {FlagGroup} */ (frozen));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* @param {object} entry
|
|
130
|
+
* @returns {ForbiddenEntry}
|
|
131
|
+
*/
|
|
132
|
+
function freezeEntry(entry) {
|
|
133
|
+
/** @type {Record<string, unknown>} */
|
|
134
|
+
const frozen = { ...entry };
|
|
135
|
+
for (const key of ['tokens', 'command', 'paths']) {
|
|
136
|
+
if (Array.isArray(frozen[key])) {
|
|
137
|
+
frozen[key] = Object.freeze([...frozen[key]]);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
if (Array.isArray(frozen.prefixes)) {
|
|
141
|
+
frozen.prefixes = Object.freeze(frozen.prefixes.map((p) => Object.freeze([...p])));
|
|
142
|
+
}
|
|
143
|
+
if (Array.isArray(frozen.groups)) {
|
|
144
|
+
frozen.groups = Object.freeze(frozen.groups.map(freezeGroup));
|
|
145
|
+
}
|
|
146
|
+
return Object.freeze(/** @type {ForbiddenEntry} */ (frozen));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** @type {ReadonlyArray<ForbiddenEntry>} */
|
|
150
|
+
export const FORBIDDEN = Object.freeze(
|
|
151
|
+
[
|
|
152
|
+
{ id: 'gh-pr-ready', kind: 'prefix', tokens: ['gh', 'pr', 'ready'], description: 'Mark a PR ready for review' },
|
|
153
|
+
{ id: 'gh-pr-merge', kind: 'prefix', tokens: ['gh', 'pr', 'merge'], description: 'Merge a PR' },
|
|
154
|
+
{ id: 'gh-pr-close', kind: 'prefix', tokens: ['gh', 'pr', 'close'], description: 'Close a PR' },
|
|
155
|
+
{
|
|
156
|
+
id: 'gh-pr-base-retarget',
|
|
157
|
+
kind: 'commandFlag',
|
|
158
|
+
command: ['gh', 'pr', 'edit'],
|
|
159
|
+
// `-B` as a prefix also covers pflag's attached form `-Bmain`.
|
|
160
|
+
groups: [{ anyOf: ['--base', '-B'], anyPrefixOf: ['--base=', '-B'] }],
|
|
161
|
+
description: 'Retarget a PR base branch (--base / -B / --base=, anywhere after `gh pr edit`)',
|
|
162
|
+
},
|
|
163
|
+
{
|
|
164
|
+
id: 'git-push-force',
|
|
165
|
+
kind: 'commandFlag',
|
|
166
|
+
command: ['git', 'push'],
|
|
167
|
+
groups: [
|
|
168
|
+
{
|
|
169
|
+
anyOf: ['--force', '-f', '--force-with-lease', '--force-if-includes', '--mirror'],
|
|
170
|
+
anyPrefixOf: ['--force-with-lease=', '--force-if-includes=', '+'],
|
|
171
|
+
shortLetters: ['f'],
|
|
172
|
+
},
|
|
173
|
+
],
|
|
174
|
+
description:
|
|
175
|
+
'Force-push (rewrites remote history): --force/-f (also inside a cluster like -uf), ' +
|
|
176
|
+
'--force-with-lease[=…], --force-if-includes, --mirror, or a +refspec',
|
|
177
|
+
},
|
|
178
|
+
{
|
|
179
|
+
id: 'git-reset-hard',
|
|
180
|
+
kind: 'commandFlag',
|
|
181
|
+
command: ['git', 'reset'],
|
|
182
|
+
groups: [{ anyOf: ['--hard'] }],
|
|
183
|
+
description: 'Discard local changes (--hard anywhere after `git reset`)',
|
|
184
|
+
},
|
|
185
|
+
{
|
|
186
|
+
id: 'git-checkout-discard',
|
|
187
|
+
kind: 'commandFlag',
|
|
188
|
+
command: ['git', 'checkout'],
|
|
189
|
+
groups: [{ anyOf: ['--', '--force'], shortLetters: ['f'] }],
|
|
190
|
+
description: 'Discard working-tree changes (`git checkout [<ref>] -- <path>`, or a forced checkout)',
|
|
191
|
+
},
|
|
192
|
+
{
|
|
193
|
+
id: 'git-restore',
|
|
194
|
+
kind: 'prefix',
|
|
195
|
+
tokens: ['git', 'restore'],
|
|
196
|
+
description: 'Discard working-tree changes to a path (the modern replacement for checkout --)',
|
|
197
|
+
},
|
|
198
|
+
{ id: 'git-clean', kind: 'prefix', tokens: ['git', 'clean'], description: 'Delete untracked files' },
|
|
199
|
+
{ id: 'git-stash', kind: 'prefix', tokens: ['git', 'stash'], description: 'Stash working-tree state' },
|
|
200
|
+
{
|
|
201
|
+
id: 'git-branch-force-delete',
|
|
202
|
+
kind: 'commandFlag',
|
|
203
|
+
command: ['git', 'branch'],
|
|
204
|
+
groups: [{ anyOf: ['-D'], shortLetters: ['D'] }],
|
|
205
|
+
description: 'Force-delete a branch (short form -D, also inside a cluster)',
|
|
206
|
+
},
|
|
207
|
+
{
|
|
208
|
+
id: 'git-branch-force-delete-long',
|
|
209
|
+
kind: 'commandFlag',
|
|
210
|
+
command: ['git', 'branch'],
|
|
211
|
+
groups: [
|
|
212
|
+
{ anyOf: ['--delete'], shortLetters: ['d'] },
|
|
213
|
+
{ anyOf: ['--force'], shortLetters: ['f'] },
|
|
214
|
+
],
|
|
215
|
+
description: 'Force-delete a branch (--delete/-d together with --force/-f, any order)',
|
|
216
|
+
},
|
|
217
|
+
{
|
|
218
|
+
id: 'git-rm',
|
|
219
|
+
kind: 'prefix',
|
|
220
|
+
tokens: ['git', 'rm'],
|
|
221
|
+
description: 'Delete a tracked file via git — includes deleting a test file',
|
|
222
|
+
},
|
|
223
|
+
{
|
|
224
|
+
id: 'rm-rf',
|
|
225
|
+
kind: 'commandFlag',
|
|
226
|
+
command: ['rm'],
|
|
227
|
+
groups: [{ anyOf: ['--recursive', '--force'], shortLetters: ['r', 'R', 'f'] }],
|
|
228
|
+
description:
|
|
229
|
+
'rm with ANY recursive or force flag (-r, -R, -f, any cluster containing one, --recursive, ' +
|
|
230
|
+
'--force) — a coder never legitimately needs either; the package cleans .code-forge/ itself',
|
|
231
|
+
},
|
|
232
|
+
{
|
|
233
|
+
id: 'production-marker',
|
|
234
|
+
kind: 'contains',
|
|
235
|
+
tokens: ['--env=production'],
|
|
236
|
+
description:
|
|
237
|
+
'Any argv token CONTAINING a configured production.markers/production.names value (env flag, DB ' +
|
|
238
|
+
'name, prod host, deploy tool) — this entry is a placeholder; mergeForbidden() adds the real, ' +
|
|
239
|
+
'project-configured tokens',
|
|
240
|
+
},
|
|
241
|
+
// ★ B8 (C4, C18): the signer's files and the worker, plan §8.4 / §8.6.
|
|
242
|
+
{
|
|
243
|
+
id: 'code-forge-runs-access',
|
|
244
|
+
kind: 'path',
|
|
245
|
+
paths: ['~/.code-forge/runs'],
|
|
246
|
+
access: 'readwrite',
|
|
247
|
+
description:
|
|
248
|
+
'Any read or write of ~/.code-forge/runs/ (the run record pin and the per-run HMAC key) — ' +
|
|
249
|
+
'a coder never needs it; reading the key is a named rule-break, not a boundary (§8.6)',
|
|
250
|
+
},
|
|
251
|
+
{
|
|
252
|
+
id: 'code-forge-worker-from-coder',
|
|
253
|
+
kind: 'anyPrefix',
|
|
254
|
+
prefixes: [
|
|
255
|
+
['code-forge', 'worker'],
|
|
256
|
+
['forge', 'worker'],
|
|
257
|
+
['code-forge', 'run', 'start'],
|
|
258
|
+
['forge', 'run', 'start'],
|
|
259
|
+
],
|
|
260
|
+
description: 'Start a worker or a run (forge worker / code-forge worker / run start) from a coder — the orchestrator only',
|
|
261
|
+
},
|
|
262
|
+
{
|
|
263
|
+
id: 'code-forge-reviews-write',
|
|
264
|
+
kind: 'path',
|
|
265
|
+
paths: ['.code-forge/reviews', '.code-forge/queue/*.done'],
|
|
266
|
+
access: 'write',
|
|
267
|
+
description: 'Any write under .code-forge/reviews/ or to .code-forge/queue/*.done — only the worker writes review results',
|
|
268
|
+
},
|
|
269
|
+
].map(freezeEntry),
|
|
270
|
+
);
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Coder-only entries (B12b, plan §4.9): verbs only a human (through the orchestrator) may run.
|
|
274
|
+
* They are NOT part of `FORBIDDEN` (whose rendered count other blocks pin); `mergeForbidden()`
|
|
275
|
+
* always appends them, so every list built for a coder carries them.
|
|
276
|
+
* - `code-forge block waive` — waiving a review finding is human-only;
|
|
277
|
+
* - `--no-require-reviews` — the human's way out of `block close`'s per-file review check.
|
|
278
|
+
* @type {ReadonlyArray<ForbiddenEntry>}
|
|
279
|
+
*/
|
|
280
|
+
export const CODER_ONLY_FORBIDDEN = Object.freeze(
|
|
281
|
+
[
|
|
282
|
+
{
|
|
283
|
+
id: 'code-forge-block-waive-from-coder',
|
|
284
|
+
kind: 'anyPrefix',
|
|
285
|
+
prefixes: [
|
|
286
|
+
['code-forge', 'block', 'waive'],
|
|
287
|
+
['forge', 'block', 'waive'],
|
|
288
|
+
],
|
|
289
|
+
description: 'Waive a review finding (forge block waive) — human-only, run by the orchestrator after the human said so (§4.9)',
|
|
290
|
+
},
|
|
291
|
+
{
|
|
292
|
+
id: 'code-forge-no-require-reviews',
|
|
293
|
+
kind: 'contains',
|
|
294
|
+
tokens: ['--no-require-reviews'],
|
|
295
|
+
description: 'Close a block without the per-file review check (--no-require-reviews) — human-only',
|
|
296
|
+
},
|
|
297
|
+
].map(freezeEntry),
|
|
298
|
+
);
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Merge project-specific tokens (from `production.markers` / `production.names`, schema owned by
|
|
302
|
+
* B1) into the static list as additional `contains` entries, after the coder-only entries
|
|
303
|
+
* (`CODER_ONLY_FORBIDDEN`, always included). The static list never changes.
|
|
304
|
+
* `contains` entries are never rendered into a CLI rule string (see the renderers), so a token
|
|
305
|
+
* may hold any character — `db.prod:5432` and `postgres://prod-host` are both valid.
|
|
306
|
+
*
|
|
307
|
+
* @param {string[]} [extraTokens]
|
|
308
|
+
* @returns {ForbiddenEntry[]}
|
|
309
|
+
* @throws {TypeError} if `extraTokens` is not an array (a bare string would otherwise be iterated
|
|
310
|
+
* one character at a time), or if any token is not a non-empty, non-whitespace-only string —
|
|
311
|
+
* an empty token is a substring of every argv token and would forbid everything.
|
|
312
|
+
*/
|
|
313
|
+
export function mergeForbidden(extraTokens = []) {
|
|
314
|
+
if (!Array.isArray(extraTokens)) {
|
|
315
|
+
throw new TypeError(`mergeForbidden: extraTokens must be an array of strings, got ${typeof extraTokens}`);
|
|
316
|
+
}
|
|
317
|
+
for (const token of extraTokens) {
|
|
318
|
+
if (typeof token !== 'string' || token.trim().length === 0) {
|
|
319
|
+
throw new TypeError(`mergeForbidden: invalid token ${JSON.stringify(token)} — must be a non-empty string`);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
const extras = extraTokens.map((token, i) =>
|
|
323
|
+
freezeEntry({
|
|
324
|
+
id: `production-marker-extra-${i}`,
|
|
325
|
+
kind: 'contains',
|
|
326
|
+
tokens: [token],
|
|
327
|
+
description: `Configured production marker: ${token}`,
|
|
328
|
+
}),
|
|
329
|
+
);
|
|
330
|
+
return [...FORBIDDEN, ...CODER_ONLY_FORBIDDEN, ...extras];
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// ── Normalization ────────────────────────────────────────────────────────────
|
|
334
|
+
|
|
335
|
+
/** git global options that take the NEXT argv token as their value (when not written `--opt=value`). */
|
|
336
|
+
const GIT_GLOBAL_WITH_VALUE = new Set([
|
|
337
|
+
'-C',
|
|
338
|
+
'-c',
|
|
339
|
+
'--git-dir',
|
|
340
|
+
'--work-tree',
|
|
341
|
+
'--namespace',
|
|
342
|
+
'--config-env',
|
|
343
|
+
'--attr-source',
|
|
344
|
+
'--super-prefix',
|
|
345
|
+
'--list-cmds',
|
|
346
|
+
]);
|
|
347
|
+
|
|
348
|
+
/** git global options that stand alone. */
|
|
349
|
+
const GIT_GLOBAL_NO_VALUE = new Set([
|
|
350
|
+
'-p',
|
|
351
|
+
'-P',
|
|
352
|
+
'--paginate',
|
|
353
|
+
'--no-pager',
|
|
354
|
+
'--bare',
|
|
355
|
+
'--no-replace-objects',
|
|
356
|
+
'--literal-pathspecs',
|
|
357
|
+
'--glob-pathspecs',
|
|
358
|
+
'--noglob-pathspecs',
|
|
359
|
+
'--icase-pathspecs',
|
|
360
|
+
'--no-optional-locks',
|
|
361
|
+
'--no-lazy-fetch',
|
|
362
|
+
'--no-advice',
|
|
363
|
+
'--exec-path',
|
|
364
|
+
'--html-path',
|
|
365
|
+
'--man-path',
|
|
366
|
+
'--info-path',
|
|
367
|
+
]);
|
|
368
|
+
|
|
369
|
+
/** Upper bound on alternative readings of an argv with unknown git global options. */
|
|
370
|
+
const MAX_GIT_READINGS = 64;
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* @param {string} token
|
|
374
|
+
* @returns {string} the command name: basename, minus a Windows `.exe` suffix.
|
|
375
|
+
*/
|
|
376
|
+
function commandName(token) {
|
|
377
|
+
return path.basename(token).replace(/\.exe$/i, '');
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Every plausible `[subcommand, ...rest]` tail of a git argv, after skipping global options.
|
|
382
|
+
* @param {ReadonlyArray<string>} argv
|
|
383
|
+
* @param {number} i
|
|
384
|
+
* @param {string[][]} out
|
|
385
|
+
*/
|
|
386
|
+
function collectGitTails(argv, i, out) {
|
|
387
|
+
while (i < argv.length && out.length < MAX_GIT_READINGS) {
|
|
388
|
+
const token = argv[i];
|
|
389
|
+
if (!token.startsWith('-')) {
|
|
390
|
+
out.push(argv.slice(i));
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
if (token === '--' || (token.startsWith('--') && token.includes('=')) || GIT_GLOBAL_NO_VALUE.has(token)) {
|
|
394
|
+
i += 1;
|
|
395
|
+
} else if (GIT_GLOBAL_WITH_VALUE.has(token)) {
|
|
396
|
+
i += 2;
|
|
397
|
+
} else if (/^-[Cc]./.test(token)) {
|
|
398
|
+
i += 1; // value attached to -C / -c
|
|
399
|
+
} else {
|
|
400
|
+
// Unknown option: fail closed by trying it both as a lone flag and as one taking a value.
|
|
401
|
+
collectGitTails(argv, i + 2, out);
|
|
402
|
+
i += 1;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* @param {ReadonlyArray<string>} argv
|
|
409
|
+
* @returns {string[][]} the normalized readings of argv to match entries against.
|
|
410
|
+
*/
|
|
411
|
+
function normalizedReadings(argv) {
|
|
412
|
+
const name = commandName(argv[0]);
|
|
413
|
+
if (name !== 'git') {
|
|
414
|
+
return [[name, ...argv.slice(1)]];
|
|
415
|
+
}
|
|
416
|
+
/** @type {string[][]} */
|
|
417
|
+
const tails = [];
|
|
418
|
+
collectGitTails(argv, 1, tails);
|
|
419
|
+
return tails.length > 0 ? tails.map((tail) => ['git', ...tail]) : [['git']];
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* Unwrap a launcher so `npx -y @codedology/code-forge worker` reads as `code-forge worker`.
|
|
424
|
+
* @param {ReadonlyArray<string>} reading
|
|
425
|
+
* @returns {ReadonlyArray<string>}
|
|
426
|
+
*/
|
|
427
|
+
function unwrapLauncher(reading) {
|
|
428
|
+
let rest = reading;
|
|
429
|
+
const skipOptions = () => {
|
|
430
|
+
while (rest.length > 0 && rest[0].startsWith('-')) {
|
|
431
|
+
const takesValue = rest[0] === '-p' || rest[0] === '--package' || rest[0] === '-r' || rest[0] === '--require';
|
|
432
|
+
rest = rest.slice(takesValue ? 2 : 1);
|
|
433
|
+
}
|
|
434
|
+
};
|
|
435
|
+
if (rest[0] === 'npx' || rest[0] === 'bunx') {
|
|
436
|
+
rest = rest.slice(1);
|
|
437
|
+
skipOptions();
|
|
438
|
+
} else if ((rest[0] === 'pnpm' || rest[0] === 'yarn') && (rest[1] === 'exec' || rest[1] === 'dlx')) {
|
|
439
|
+
rest = rest.slice(2);
|
|
440
|
+
skipOptions();
|
|
441
|
+
} else if (rest[0] === 'node') {
|
|
442
|
+
rest = rest.slice(1);
|
|
443
|
+
skipOptions();
|
|
444
|
+
}
|
|
445
|
+
if (rest.length === 0) return rest;
|
|
446
|
+
const isPackage = (/** @type {string} */ t) => /^@codedology\/code-forge(@.*)?$/.test(t) || /^code-forge(\.mjs)?(@.*)?$/.test(path.basename(t));
|
|
447
|
+
const head = rest[0];
|
|
448
|
+
let tail = rest.slice(1);
|
|
449
|
+
if (isPackage(head) && tail.length > 0 && isPackage(tail[0])) tail = tail.slice(1);
|
|
450
|
+
return [isPackage(head) ? 'code-forge' : commandName(head), ...tail.filter((t) => !t.startsWith('-'))];
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/** Commands that write the file they are given (`>`/`>>` and the Claude file tools come from transcripts). */
|
|
454
|
+
const WRITE_VERBS = new Set([
|
|
455
|
+
'cp', 'mv', 'tee', 'touch', 'rm', 'ln', 'install', 'truncate', 'dd', 'rsync', 'chmod', 'chown',
|
|
456
|
+
'unlink', 'mkdir', 'rmdir', '>', '>>', 'Write', 'Edit', 'MultiEdit', 'NotebookEdit',
|
|
457
|
+
]);
|
|
458
|
+
|
|
459
|
+
/** git subcommands that write a path they name. */
|
|
460
|
+
const GIT_WRITE_SUBCOMMANDS = new Set(['mv', 'rm', 'restore', 'checkout']);
|
|
461
|
+
|
|
462
|
+
/** Wrappers skipped before the write verb is read. */
|
|
463
|
+
const WRAPPERS = new Set(['sudo', 'env', 'xargs', 'nice', 'timeout', 'command', 'nohup']);
|
|
464
|
+
|
|
465
|
+
/** @param {string} token @returns {boolean} `sed -i`, `-i.bak`, `perl -pi`, `--in-place[=…]` */
|
|
466
|
+
const isInPlaceFlag = (token) => /^-[a-zA-Z]*i/.test(token) || /^--in-place(=|$)/.test(token);
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* Does `argv` (a raw command, wrappers allowed) write the files it names?
|
|
470
|
+
* @param {ReadonlyArray<string>} argv
|
|
471
|
+
* @returns {boolean}
|
|
472
|
+
*/
|
|
473
|
+
function isWriteCommand(argv) {
|
|
474
|
+
let i = 0;
|
|
475
|
+
while (i < argv.length && WRAPPERS.has(commandName(argv[i]))) {
|
|
476
|
+
i += 1;
|
|
477
|
+
// wrapper options, `VAR=x` assignments (env) and a duration (timeout) are not the verb
|
|
478
|
+
while (i < argv.length && (argv[i].startsWith('-') || /^[A-Za-z_][A-Za-z0-9_]*=/.test(argv[i]) || /^\d+(\.\d+)?[smhd]?$/.test(argv[i]))) i += 1;
|
|
479
|
+
}
|
|
480
|
+
const verb = i < argv.length ? commandName(argv[i]) : '';
|
|
481
|
+
if (WRITE_VERBS.has(verb)) return true;
|
|
482
|
+
if ((verb === 'sed' || verb === 'perl') && argv.slice(i + 1).some(isInPlaceFlag)) return true;
|
|
483
|
+
if (verb === 'git') return normalizedReadings(argv.slice(i)).some((r) => GIT_WRITE_SUBCOMMANDS.has(r[1]));
|
|
484
|
+
return false;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/** @param {string} text */
|
|
488
|
+
const escapeRegExp = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
489
|
+
|
|
490
|
+
/** @type {Map<string, RegExp>} compiled path patterns, keyed by home dir + path */
|
|
491
|
+
const pathPatternCache = new Map();
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* A regex matching an argv token that names protected path `p` (see the `path` kind doc).
|
|
495
|
+
* @param {string} p
|
|
496
|
+
* @returns {RegExp}
|
|
497
|
+
*/
|
|
498
|
+
function pathPattern(p) {
|
|
499
|
+
const home = os.homedir();
|
|
500
|
+
const cacheKey = `${home}\u0000${p}`;
|
|
501
|
+
const cached = pathPatternCache.get(cacheKey);
|
|
502
|
+
if (cached) return cached;
|
|
503
|
+
const glob = (/** @type {string} */ s) => s.split('*').map(escapeRegExp).join('[^/\\s]*');
|
|
504
|
+
let pattern;
|
|
505
|
+
if (p.startsWith('~/')) {
|
|
506
|
+
const homes = ['~', '\\$HOME', '\\$\\{HOME\\}', escapeRegExp(home)];
|
|
507
|
+
pattern = new RegExp(`(?:^|[=:\\s'"])(?:${homes.join('|')})/${glob(p.slice(2))}(?:/|$|[\\s'";)])`);
|
|
508
|
+
} else {
|
|
509
|
+
pattern = new RegExp(`(?:^|[/=:\\s'"])${glob(p)}(?:/|$|[\\s'";)])`);
|
|
510
|
+
}
|
|
511
|
+
pathPatternCache.set(cacheKey, pattern);
|
|
512
|
+
return pattern;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/** @param {string} token @param {ForbiddenEntry} entry @returns {boolean} */
|
|
516
|
+
const namesPath = (token, entry) => entry.paths.some((p) => pathPattern(p).test(token));
|
|
517
|
+
|
|
518
|
+
// ── Matching ─────────────────────────────────────────────────────────────────
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* @param {ReadonlyArray<string>} argv
|
|
522
|
+
* @param {ReadonlyArray<string>} prefix
|
|
523
|
+
* @returns {boolean}
|
|
524
|
+
*/
|
|
525
|
+
function startsWithPrefix(argv, prefix) {
|
|
526
|
+
if (argv.length < prefix.length) return false;
|
|
527
|
+
return prefix.every((token, i) => argv[i] === token);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/**
|
|
531
|
+
* @param {string} token
|
|
532
|
+
* @param {FlagGroup} group
|
|
533
|
+
* @returns {boolean}
|
|
534
|
+
*/
|
|
535
|
+
function tokenMatchesGroup(token, group) {
|
|
536
|
+
if (group.anyOf?.includes(token)) return true;
|
|
537
|
+
if (group.anyPrefixOf?.some((p) => token.startsWith(p))) return true;
|
|
538
|
+
if (group.shortLetters && /^-[^-]/.test(token)) {
|
|
539
|
+
const cluster = token.slice(1);
|
|
540
|
+
return group.shortLetters.some((letter) => cluster.includes(letter));
|
|
541
|
+
}
|
|
542
|
+
return false;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/**
|
|
546
|
+
* @param {ReadonlyArray<string>} reading - a normalized argv
|
|
547
|
+
* @param {ReadonlyArray<string>} rawArgv - the argv as given (for `contains`)
|
|
548
|
+
* @param {ForbiddenEntry} entry
|
|
549
|
+
* @returns {boolean}
|
|
550
|
+
*/
|
|
551
|
+
function matchesEntry(reading, rawArgv, entry) {
|
|
552
|
+
if (entry.kind === 'contains') {
|
|
553
|
+
return entry.tokens.every((t) => rawArgv.some((a) => a.includes(t)));
|
|
554
|
+
}
|
|
555
|
+
if (entry.kind === 'prefix') {
|
|
556
|
+
return startsWithPrefix(reading, entry.tokens);
|
|
557
|
+
}
|
|
558
|
+
if (entry.kind === 'anyPrefix') {
|
|
559
|
+
const unwrapped = unwrapLauncher(reading);
|
|
560
|
+
return entry.prefixes.some((prefix) => startsWithPrefix(unwrapped, prefix));
|
|
561
|
+
}
|
|
562
|
+
if (entry.kind === 'path') {
|
|
563
|
+
const named = rawArgv.some((token) => namesPath(token, entry));
|
|
564
|
+
if (!named || entry.access === 'readwrite') return named;
|
|
565
|
+
return isWriteCommand(rawArgv);
|
|
566
|
+
}
|
|
567
|
+
if (!startsWithPrefix(reading, entry.command)) return false;
|
|
568
|
+
const rest = reading.slice(entry.command.length);
|
|
569
|
+
return entry.groups.every((group) => rest.some((token) => tokenMatchesGroup(token, group)));
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
/**
|
|
573
|
+
* The package's own structural check (see the module doc for what it normalizes and its limits).
|
|
574
|
+
*
|
|
575
|
+
* @param {ReadonlyArray<string>} argv
|
|
576
|
+
* @param {ReadonlyArray<ForbiddenEntry>} [list]
|
|
577
|
+
* @returns {ForbiddenEntry | null} the first matching entry in list order, or null if argv is clear.
|
|
578
|
+
* @throws {TypeError} if argv is not an array of strings.
|
|
579
|
+
*/
|
|
580
|
+
export function isForbidden(argv, list = FORBIDDEN) {
|
|
581
|
+
if (!Array.isArray(argv) || !argv.every((a) => typeof a === 'string')) {
|
|
582
|
+
throw new TypeError('isForbidden: argv must be an array of strings');
|
|
583
|
+
}
|
|
584
|
+
if (argv.length === 0) return null;
|
|
585
|
+
const readings = normalizedReadings(argv);
|
|
586
|
+
for (const entry of list) {
|
|
587
|
+
if (readings.some((reading) => matchesEntry(reading, argv, entry))) {
|
|
588
|
+
return entry;
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
return null;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
// ── Rendering ────────────────────────────────────────────────────────────────
|
|
595
|
+
|
|
596
|
+
/**
|
|
597
|
+
* The literal flag spellings a prefix rule should carry for one group: every `anyOf` flag, every
|
|
598
|
+
* short letter as `-x`, and every two-letter cluster of distinct short letters (`-rf`, `-fR`, …).
|
|
599
|
+
* `anyPrefixOf` forms (`--base=`, `+refspec`) have no fixed spelling and are left to `isForbidden`.
|
|
600
|
+
* @param {FlagGroup} group
|
|
601
|
+
* @returns {string[]}
|
|
602
|
+
*/
|
|
603
|
+
function groupSpellings(group) {
|
|
604
|
+
const spellings = [...(group.anyOf ?? [])];
|
|
605
|
+
const letters = group.shortLetters ?? [];
|
|
606
|
+
for (const a of letters) {
|
|
607
|
+
spellings.push(`-${a}`);
|
|
608
|
+
}
|
|
609
|
+
for (const a of letters) {
|
|
610
|
+
for (const b of letters) {
|
|
611
|
+
if (a !== b) spellings.push(`-${a}${b}`);
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
return [...new Set(spellings)];
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/**
|
|
618
|
+
* Every ordering of the groups × every choice of spelling per group.
|
|
619
|
+
* @param {ReadonlyArray<FlagGroup>} groups
|
|
620
|
+
* @returns {string[][]}
|
|
621
|
+
*/
|
|
622
|
+
function flagSequences(groups) {
|
|
623
|
+
if (groups.length === 0) return [[]];
|
|
624
|
+
/** @type {string[][]} */
|
|
625
|
+
const sequences = [];
|
|
626
|
+
groups.forEach((group, i) => {
|
|
627
|
+
const others = [...groups.slice(0, i), ...groups.slice(i + 1)];
|
|
628
|
+
for (const spelling of groupSpellings(group)) {
|
|
629
|
+
for (const tail of flagSequences(others)) {
|
|
630
|
+
sequences.push([spelling, ...tail]);
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
});
|
|
634
|
+
return sequences;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/**
|
|
638
|
+
* The token-array prefixes an entry renders to (empty for `contains`: not expressible).
|
|
639
|
+
* @param {ForbiddenEntry} entry
|
|
640
|
+
* @returns {string[][]}
|
|
641
|
+
*/
|
|
642
|
+
function renderPatterns(entry) {
|
|
643
|
+
if (entry.kind === 'prefix') {
|
|
644
|
+
return [[...entry.tokens]];
|
|
645
|
+
}
|
|
646
|
+
if (entry.kind === 'commandFlag') {
|
|
647
|
+
const seen = new Set();
|
|
648
|
+
/** @type {string[][]} */
|
|
649
|
+
const patterns = [];
|
|
650
|
+
for (const flags of flagSequences(entry.groups)) {
|
|
651
|
+
const pattern = [...entry.command, ...flags];
|
|
652
|
+
const key = pattern.join('\u0000');
|
|
653
|
+
if (!seen.has(key)) {
|
|
654
|
+
seen.add(key);
|
|
655
|
+
patterns.push(pattern);
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
return patterns;
|
|
659
|
+
}
|
|
660
|
+
if (entry.kind === 'anyPrefix') {
|
|
661
|
+
return entry.prefixes.flatMap((prefix) =>
|
|
662
|
+
prefix[0] === 'code-forge'
|
|
663
|
+
? [[...prefix], ['npx', ...prefix], ['npx', '@codedology/code-forge', ...prefix.slice(1)]]
|
|
664
|
+
: [[...prefix]],
|
|
665
|
+
);
|
|
666
|
+
}
|
|
667
|
+
return [];
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
/**
|
|
671
|
+
* File-tool rules for a `path` entry: `Read`/`Edit`/`Write` (`Read` only for `readwrite`), a
|
|
672
|
+
* directory widened to `<dir>/**`.
|
|
673
|
+
* @param {ForbiddenEntry} entry
|
|
674
|
+
* @returns {string[]}
|
|
675
|
+
*/
|
|
676
|
+
function pathRules(entry) {
|
|
677
|
+
if (entry.kind !== 'path') return [];
|
|
678
|
+
const tools = entry.access === 'readwrite' ? ['Read', 'Edit', 'Write'] : ['Edit', 'Write'];
|
|
679
|
+
return entry.paths.flatMap((p) => tools.map((tool) => `${tool}(${p.includes('*') ? p : `${p}/**`})`));
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
/**
|
|
683
|
+
* Render the list as Claude Code `--disallowedTools` values, e.g. `Bash(git reset --hard:*)`.
|
|
684
|
+
* One item per entry; `rules` holds one prefix rule per flag spelling (see the module doc for
|
|
685
|
+
* what prefix rules cannot reach), plus the file-tool rules of a `path` entry. `enforced` is
|
|
686
|
+
* false only when no rule can be expressed.
|
|
687
|
+
* @param {ReadonlyArray<ForbiddenEntry>} [list]
|
|
688
|
+
* @returns {{id: string, rules: string[], enforced: boolean}[]}
|
|
689
|
+
*/
|
|
690
|
+
export function renderForClaude(list = FORBIDDEN) {
|
|
691
|
+
return list.map((entry) => {
|
|
692
|
+
const rules = [...renderPatterns(entry).map((pattern) => `Bash(${pattern.join(' ')}:*)`), ...pathRules(entry)];
|
|
693
|
+
return { id: entry.id, rules, enforced: rules.length > 0 };
|
|
694
|
+
});
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
/**
|
|
698
|
+
* Render the list as Grok `--deny` rule values. Grok's `--deny` is the documented compat alias
|
|
699
|
+
* of `--disallowedTools` (`grok --help`, pinned fixture), so it shares Claude's rule syntax.
|
|
700
|
+
* @param {ReadonlyArray<ForbiddenEntry>} [list]
|
|
701
|
+
* @returns {{id: string, rules: string[], enforced: boolean}[]}
|
|
702
|
+
*/
|
|
703
|
+
export function renderForGrok(list = FORBIDDEN) {
|
|
704
|
+
return renderForClaude(list);
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
/**
|
|
708
|
+
* Render the list as execpolicy rule objects for Codex's rules file (the file wiring is probed
|
|
709
|
+
* and pinned by `src/engines/**`, a later block; this only shapes the data). Each `patterns`
|
|
710
|
+
* element is a token-array prefix; `decision` is always `forbidden`.
|
|
711
|
+
* @param {ReadonlyArray<ForbiddenEntry>} [list]
|
|
712
|
+
* @returns {{id: string, patterns: string[][], decision: 'forbidden', enforced: boolean, description: string}[]}
|
|
713
|
+
*/
|
|
714
|
+
export function renderForCodex(list = FORBIDDEN) {
|
|
715
|
+
return list.map((entry) => {
|
|
716
|
+
const patterns = renderPatterns(entry);
|
|
717
|
+
return {
|
|
718
|
+
id: entry.id,
|
|
719
|
+
patterns,
|
|
720
|
+
decision: /** @type {'forbidden'} */ ('forbidden'),
|
|
721
|
+
enforced: patterns.length > 0,
|
|
722
|
+
description: entry.description,
|
|
723
|
+
};
|
|
724
|
+
});
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
// ── Transcripts ──────────────────────────────────────────────────────────────
|
|
728
|
+
|
|
729
|
+
/**
|
|
730
|
+
* Scan captured coder output (subprocess stdout, Solo `search_output` text, a harness log) for
|
|
731
|
+
* any entry of `list`, line by line: each line is split into shell-ish tokens (whitespace,
|
|
732
|
+
* quotes, `;|&(),{}[]`; `>`/`>>` kept as their own token), so a command anywhere in the line —
|
|
733
|
+
* `$ cat ~/.code-forge/runs/r.key`, `Write(.code-forge/reviews/x.json)`, a stream-json
|
|
734
|
+
* `"name":"Write"` followed by the path — is found. It is a grep: a line that merely MENTIONS a
|
|
735
|
+
* forbidden command is a hit too (fail closed; the block gate reports it as `rule_break`, B5).
|
|
736
|
+
*
|
|
737
|
+
* Linear in the line length: `contains` and `path` entries are evaluated ONCE per line over its
|
|
738
|
+
* tokens (a `write` path entry hits when a write command starts before a token naming the path);
|
|
739
|
+
* command entries run only at tokens that can start one (their first word, a launcher, a
|
|
740
|
+
* code-forge bin), each on a window of `TRANSCRIPT_WINDOW` tokens — a flag further than that
|
|
741
|
+
* from its command is not seen. One hit per (line, id), in list order.
|
|
742
|
+
* @param {string} text
|
|
743
|
+
* @param {ReadonlyArray<ForbiddenEntry>} [list]
|
|
744
|
+
* @returns {{id: string, line: number}[]}
|
|
745
|
+
*/
|
|
746
|
+
export function scanTranscript(text, list = FORBIDDEN) {
|
|
747
|
+
const commandEntries = list.filter((e) => e.kind === 'prefix' || e.kind === 'commandFlag' || e.kind === 'anyPrefix');
|
|
748
|
+
const firstWords = new Set(['npx', 'bunx', 'pnpm', 'yarn', 'node']);
|
|
749
|
+
for (const e of commandEntries) {
|
|
750
|
+
if (e.kind === 'prefix') firstWords.add(e.tokens[0]);
|
|
751
|
+
else if (e.kind === 'commandFlag') firstWords.add(e.command[0]);
|
|
752
|
+
else for (const p of e.prefixes) firstWords.add(p[0]);
|
|
753
|
+
}
|
|
754
|
+
const writeStarts = new Set([...WRITE_VERBS, ...WRAPPERS, 'sed', 'perl', 'git']);
|
|
755
|
+
/** @type {{id: string, line: number}[]} */
|
|
756
|
+
const hits = [];
|
|
757
|
+
String(text)
|
|
758
|
+
.split('\n')
|
|
759
|
+
.forEach((lineText, index) => {
|
|
760
|
+
const tokens = lineText.split(/(>>?)|[\s'"`;|&(),{}[\]]+/).filter((t) => typeof t === 'string' && t.length > 0);
|
|
761
|
+
const ids = new Set();
|
|
762
|
+
let firstWrite = -1;
|
|
763
|
+
for (let i = 0; i < tokens.length && firstWrite < 0; i += 1) {
|
|
764
|
+
if (writeStarts.has(commandName(tokens[i])) && isWriteCommand(tokens.slice(i, i + TRANSCRIPT_WINDOW))) firstWrite = i;
|
|
765
|
+
}
|
|
766
|
+
for (const entry of list) {
|
|
767
|
+
if (entry.kind === 'contains' && matchesEntry([], tokens, entry)) ids.add(entry.id);
|
|
768
|
+
if (entry.kind !== 'path') continue;
|
|
769
|
+
const named = tokens.findIndex((t, i) => (entry.access === 'readwrite' || (firstWrite >= 0 && i > firstWrite)) && namesPath(t, entry));
|
|
770
|
+
if (named >= 0) ids.add(entry.id);
|
|
771
|
+
}
|
|
772
|
+
for (let i = 0; i < tokens.length; i += 1) {
|
|
773
|
+
if (!firstWords.has(commandName(tokens[i])) && !tokens[i].includes('code-forge')) continue;
|
|
774
|
+
const window = tokens.slice(i, i + TRANSCRIPT_WINDOW);
|
|
775
|
+
for (const entry of commandEntries) {
|
|
776
|
+
if (!ids.has(entry.id) && isForbidden(window, [entry])) ids.add(entry.id);
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
for (const entry of list) {
|
|
780
|
+
if (ids.has(entry.id)) hits.push({ id: entry.id, line: index + 1 });
|
|
781
|
+
}
|
|
782
|
+
});
|
|
783
|
+
return hits;
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
/** How many tokens after a command start `scanTranscript` looks at. */
|
|
787
|
+
const TRANSCRIPT_WINDOW = 64;
|