@next-friday/create-app 9.5.0 → 9.5.1
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/package.json +1 -1
- package/templates/base/.agents/hooks/agent-mcp-gate.sh +8 -0
- package/templates/base/.agents/hooks/agent-production-gate.sh +24 -5
- package/templates/base/.agents/hooks/agent-shell-gate.sh +9 -1
- package/templates/base/.agents/hooks/agent-test-edit-gate.sh +10 -2
- package/templates/base/.agents/hooks/record-gate-decision.sh +28 -0
- package/templates/base/.cursor/hooks/agent-constitution-gate.sh +10 -2
- package/templates/base/SETUP.md +1 -1
- package/templates/base/scripts/check-agent-gates.ts +186 -6
package/package.json
CHANGED
|
@@ -1,12 +1,20 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
2
|
set -uo pipefail
|
|
3
3
|
|
|
4
|
+
# shellcheck source=.agents/hooks/record-gate-decision.sh
|
|
5
|
+
if ! source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/record-gate-decision.sh"; then
|
|
6
|
+
printf '%s\n' '{"permission":"deny","user_message":"agent-mcp-gate: .agents/hooks/record-gate-decision.sh could not be loaded, so the gate cannot record its decision. Stop and report this error to the user; agents may not edit the gate."}'
|
|
7
|
+
exit 2
|
|
8
|
+
fi
|
|
9
|
+
|
|
4
10
|
allow() {
|
|
11
|
+
record_gate_decision agent-mcp-gate allow "" "${input:-}" "${lifted_approval:-}"
|
|
5
12
|
printf '%s\n' '{"permission":"allow"}'
|
|
6
13
|
exit 0
|
|
7
14
|
}
|
|
8
15
|
|
|
9
16
|
block() {
|
|
17
|
+
record_gate_decision agent-mcp-gate block "${2:-$1}" "${input:-}"
|
|
10
18
|
jq -n --arg message "$1" '{permission:"deny",user_message:$message,agent_message:$message}'
|
|
11
19
|
printf 'agent-mcp-gate: %s\n' "$1" >&2
|
|
12
20
|
exit 2
|
|
@@ -1,12 +1,20 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
2
|
set -uo pipefail
|
|
3
3
|
|
|
4
|
+
# shellcheck source=.agents/hooks/record-gate-decision.sh
|
|
5
|
+
if ! source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/record-gate-decision.sh"; then
|
|
6
|
+
printf '%s\n' '{"permission":"deny","user_message":"agent-production-gate: .agents/hooks/record-gate-decision.sh could not be loaded, so the gate cannot record its decision. Stop and report this error to the user; agents may not edit the gate."}'
|
|
7
|
+
exit 2
|
|
8
|
+
fi
|
|
9
|
+
|
|
4
10
|
allow() {
|
|
11
|
+
record_gate_decision agent-production-gate allow "" "${input:-}" "${lifted_approval:-}"
|
|
5
12
|
printf '%s\n' '{"permission":"allow"}'
|
|
6
13
|
exit 0
|
|
7
14
|
}
|
|
8
15
|
|
|
9
16
|
block() {
|
|
17
|
+
record_gate_decision agent-production-gate block "${2:-$1}" "${input:-}"
|
|
10
18
|
jq -n --arg message "$1" '{permission:"deny",user_message:$message,agent_message:$message}'
|
|
11
19
|
printf 'agent-production-gate: %s\n' "$1" >&2
|
|
12
20
|
exit 2
|
|
@@ -14,15 +22,26 @@ block() {
|
|
|
14
22
|
|
|
15
23
|
trap 'block ".agents/hooks/agent-production-gate.sh failed. Stop and report this error to the user; agents may not edit the gate."' ERR
|
|
16
24
|
command -v jq > /dev/null || block "jq is required to read the hook input. Install it: https://jqlang.org/download/"
|
|
17
|
-
|
|
25
|
+
input=$(cat)
|
|
26
|
+
command=$(jq -r '.command // .tool_input.command // empty' <<< "$input")
|
|
18
27
|
[[ -n $command ]] || allow
|
|
19
|
-
|
|
20
|
-
|
|
28
|
+
project=${CLAUDE_PROJECT_DIR:-${CURSOR_PROJECT_DIR:-$PWD}}
|
|
29
|
+
approval_form_file=.agents/release-approval-form
|
|
30
|
+
approval=${RELEASE_APPROVAL:-}
|
|
31
|
+
unmatched_approval=
|
|
32
|
+
case $(tr '[:upper:]' '[:lower:]' <<< "$approval") in
|
|
21
33
|
"" | 0 | false | no) ;;
|
|
22
|
-
*)
|
|
34
|
+
*)
|
|
35
|
+
approval_form=$(head -n 1 "$project/$approval_form_file" 2> /dev/null) || approval_form=
|
|
36
|
+
if [[ -n $approval_form && $approval =~ $approval_form ]]; then
|
|
37
|
+
lifted_approval=$approval
|
|
38
|
+
allow
|
|
39
|
+
fi
|
|
40
|
+
unmatched_approval=" RELEASE_APPROVAL is set and does not match the form in $approval_form_file, so the gate stays in force."
|
|
41
|
+
;;
|
|
23
42
|
esac
|
|
24
43
|
|
|
25
|
-
release_gate="A release manager runs prod deploys, commands against prod, and release tag pushes. Do not run this; report what is ready to promote, such as the commit SHA. If a gated command appears only inside a message, write the text to a file and pass it with git commit -F <file> or gh pr create --body-file <file
|
|
44
|
+
release_gate="A release manager runs prod deploys, commands against prod, and release tag pushes. Do not run this; report what is ready to promote, such as the commit SHA. If a gated command appears only inside a message, write the text to a file and pass it with git commit -F <file> or gh pr create --body-file <file>.$unmatched_approval"
|
|
26
45
|
|
|
27
46
|
# The gate reads the command line as text, joining continued lines and dropping quotes,
|
|
28
47
|
# backslashes, and fd redirections, so wrappers such as bash -c, eval, or npx and split words such
|
|
@@ -1,12 +1,20 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
2
|
set -uo pipefail
|
|
3
3
|
|
|
4
|
+
# shellcheck source=.agents/hooks/record-gate-decision.sh
|
|
5
|
+
if ! source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/record-gate-decision.sh"; then
|
|
6
|
+
printf '%s\n' '{"permission":"deny","user_message":"agent-shell-gate: .agents/hooks/record-gate-decision.sh could not be loaded, so the gate cannot record its decision. Stop and report this error to the user; agents may not edit the gate."}'
|
|
7
|
+
exit 2
|
|
8
|
+
fi
|
|
9
|
+
|
|
4
10
|
allow() {
|
|
11
|
+
record_gate_decision agent-shell-gate allow "" "${input:-}" "${lifted_approval:-}"
|
|
5
12
|
printf '%s\n' '{"permission":"allow"}'
|
|
6
13
|
exit 0
|
|
7
14
|
}
|
|
8
15
|
|
|
9
16
|
block() {
|
|
17
|
+
record_gate_decision agent-shell-gate block "${2:-$1}" "${input:-}"
|
|
10
18
|
jq -n --arg message "$1" '{permission:"deny",user_message:$message,agent_message:$message}'
|
|
11
19
|
printf 'agent-shell-gate: %s\n' "$1" >&2
|
|
12
20
|
exit 2
|
|
@@ -147,7 +155,7 @@ lex() {
|
|
|
147
155
|
}
|
|
148
156
|
|
|
149
157
|
writes_protected() {
|
|
150
|
-
block "Changing $1 from the shell is blocked. $policy"
|
|
158
|
+
block "Changing $1 from the shell is blocked. $policy" "Changing a protected path from the shell is blocked. $policy"
|
|
151
159
|
}
|
|
152
160
|
|
|
153
161
|
check_git() {
|
|
@@ -1,12 +1,20 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
2
|
set -uo pipefail
|
|
3
3
|
|
|
4
|
+
# shellcheck source=.agents/hooks/record-gate-decision.sh
|
|
5
|
+
if ! source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/record-gate-decision.sh"; then
|
|
6
|
+
printf '%s\n' '{"permission":"deny","user_message":"agent-test-edit-gate: .agents/hooks/record-gate-decision.sh could not be loaded, so the gate cannot record its decision. Stop and report this error to the user; agents may not edit the gate."}'
|
|
7
|
+
exit 2
|
|
8
|
+
fi
|
|
9
|
+
|
|
4
10
|
allow() {
|
|
11
|
+
record_gate_decision agent-test-edit-gate allow "" "${input:-}" "${lifted_approval:-}"
|
|
5
12
|
printf '%s\n' '{"permission":"allow"}'
|
|
6
13
|
exit 0
|
|
7
14
|
}
|
|
8
15
|
|
|
9
16
|
block() {
|
|
17
|
+
record_gate_decision agent-test-edit-gate block "${2:-$1}" "${input:-}"
|
|
10
18
|
jq -n --arg message "$1" '{permission:"deny",user_message:$message,agent_message:$message}'
|
|
11
19
|
printf 'agent-test-edit-gate: %s\n' "$1" >&2
|
|
12
20
|
exit 2
|
|
@@ -20,7 +28,7 @@ project=${CLAUDE_PROJECT_DIR:-${CURSOR_PROJECT_DIR:-$PWD}}
|
|
|
20
28
|
path=$(jq -r '.tool_input.file_path // .tool_input.path // .tool_input.notebook_path // .file_path // empty' <<< "$input")
|
|
21
29
|
[[ -n $path && $path =~ $test_path_pattern ]] || allow
|
|
22
30
|
if [[ -e "$project/.git/bug-fix-in-progress" ]]; then
|
|
23
|
-
block "A bug fix is in progress (.git/bug-fix-in-progress), so $path cannot change. Fix the code, not the test."
|
|
31
|
+
block "A bug fix is in progress (.git/bug-fix-in-progress), so $path cannot change. Fix the code, not the test." "A bug fix is in progress (.git/bug-fix-in-progress), so a test file cannot change."
|
|
24
32
|
fi
|
|
25
33
|
branch=$(git -C "$project" symbolic-ref --quiet --short HEAD 2> /dev/null) || branch=
|
|
26
34
|
[[ $branch == fix/* ]] || allow
|
|
@@ -29,6 +37,6 @@ git -C "$project" rev-parse --verify --quiet "$default_branch" > /dev/null 2>&1
|
|
|
29
37
|
merge_base=$(git -C "$project" merge-base HEAD "$default_branch" 2> /dev/null) || allow
|
|
30
38
|
committed_files=$(git -C "$project" log --format= --name-only "$merge_base..HEAD" 2> /dev/null) || allow
|
|
31
39
|
if grep -Eq "$test_path_pattern" <<< "$committed_files"; then
|
|
32
|
-
block "A bug fix is in progress on branch $branch and the test that reproduces it is committed, so $path cannot change. Fix the code, not the test."
|
|
40
|
+
block "A bug fix is in progress on branch $branch and the test that reproduces it is committed, so $path cannot change. Fix the code, not the test." "A bug fix is in progress on a fix branch whose test is committed, so a test file cannot change."
|
|
33
41
|
fi
|
|
34
42
|
allow
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Sourced by each gate hook. A record never changes the hook's answer: a failed write is reported
|
|
3
|
+
# on standard error and the function returns 0. The record holds no command text, path, file
|
|
4
|
+
# content, or environment value, because any of them can hold a secret: a hook passes a reason
|
|
5
|
+
# with no text from its input. A lifted production gate adds the approval value and the Git user
|
|
6
|
+
# name, which the release record needs.
|
|
7
|
+
record_gate_decision() {
|
|
8
|
+
local hook=$1 decision=$2 reason=$3 input=$4 approval=${5:-} project log session
|
|
9
|
+
project=${CLAUDE_PROJECT_DIR:-${CURSOR_PROJECT_DIR:-$PWD}}
|
|
10
|
+
log=${AGENT_GATE_LOG:-$project/.agents/logs/gate-decisions.jsonl}
|
|
11
|
+
session=$(jq -r '.session_id // .conversation_id // empty' <<< "$input" 2> /dev/null) || session=
|
|
12
|
+
{
|
|
13
|
+
mkdir -p "${log%/*}" \
|
|
14
|
+
&& jq -cn \
|
|
15
|
+
--arg time "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
|
16
|
+
--arg hook "$hook" \
|
|
17
|
+
--arg decision "$decision" \
|
|
18
|
+
--arg reason "$reason" \
|
|
19
|
+
--arg session "$session" \
|
|
20
|
+
--arg approval "$approval" \
|
|
21
|
+
--arg user "$([[ -n $approval ]] && git -C "$project" config user.name 2> /dev/null)" \
|
|
22
|
+
'{time: $time, hook: $hook, decision: $decision}
|
|
23
|
+
+ (if $reason == "" then {} else {reason: $reason} end)
|
|
24
|
+
+ (if $session == "" then {} else {session: $session} end)
|
|
25
|
+
+ (if $approval == "" then {} else {approval: $approval, user: $user} end)' >> "$log"
|
|
26
|
+
} 2> /dev/null || printf '%s: could not write the decision record to %s\n' "$hook" "$log" >&2
|
|
27
|
+
return 0
|
|
28
|
+
}
|
|
@@ -3,12 +3,20 @@ set -uo pipefail
|
|
|
3
3
|
|
|
4
4
|
protected='^(\.agents/rules/|\.agents/rules\.json$|\.agents/hooks/|\.claude/rules/|\.claude/settings(\.local)?\.json$|\.cursor/rules/|\.cursor/hooks/|\.cursor/hooks\.json$|scripts/sync-agent-rules\.ts$|scripts/check-agent-gates\.ts$|\.commitlintrc\.json$|\.prettierrc\.json$|prettier\.config\.ts$|eslint\.config\.ts$|\.husky/|\.lintstagedrc\.json$|\.gitleaks\.toml$|\.git/hooks/|\.git/config(\.worktree)?$)'
|
|
5
5
|
|
|
6
|
+
# shellcheck source=.agents/hooks/record-gate-decision.sh
|
|
7
|
+
if ! source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../../.agents/hooks/record-gate-decision.sh"; then
|
|
8
|
+
printf '%s\n' '{"permission":"deny","user_message":"agent-constitution-gate: .agents/hooks/record-gate-decision.sh could not be loaded, so the gate cannot record its decision. Stop and report this error to the user; agents may not edit the gate."}'
|
|
9
|
+
exit 2
|
|
10
|
+
fi
|
|
11
|
+
|
|
6
12
|
allow() {
|
|
13
|
+
record_gate_decision agent-constitution-gate allow "" "${input:-}" "${lifted_approval:-}"
|
|
7
14
|
printf '%s\n' '{"permission":"allow"}'
|
|
8
15
|
exit 0
|
|
9
16
|
}
|
|
10
17
|
|
|
11
18
|
block() {
|
|
19
|
+
record_gate_decision agent-constitution-gate block "${2:-$1}" "${input:-}"
|
|
12
20
|
jq -n --arg message "$1" '{permission:"deny",user_message:$message,agent_message:$message}'
|
|
13
21
|
printf 'agent-constitution-gate: %s\n' "$1" >&2
|
|
14
22
|
exit 2
|
|
@@ -26,7 +34,7 @@ project=${CURSOR_PROJECT_DIR:-${CLAUDE_PROJECT_DIR:-${project:-$PWD}}}
|
|
|
26
34
|
[[ $path == "$project"/* ]] || allow
|
|
27
35
|
relative=${path#"$project"/}
|
|
28
36
|
if [[ $relative =~ (^|/)\.\.?(/|$) ]]; then
|
|
29
|
-
block "The edit to $relative uses . or .. in its path. Use the normalized path."
|
|
37
|
+
block "The edit to $relative uses . or .. in its path. Use the normalized path." "The edit uses . or .. in its path."
|
|
30
38
|
fi
|
|
31
39
|
[[ $relative =~ $protected ]] || allow
|
|
32
|
-
block "The edit to $relative is blocked. That path is company policy; stop and ask the user."
|
|
40
|
+
block "The edit to $relative is blocked. That path is company policy; stop and ask the user." "The edit to a company policy path is blocked."
|
package/templates/base/SETUP.md
CHANGED
|
@@ -92,7 +92,7 @@ Replace `security@example.com` in `SECURITY.md` with the contact the security po
|
|
|
92
92
|
- **Sources of truth (engineering lead):** for each artifact, name one system as the source of truth: this repository, an existing tool such as Jira, or, as the minimum, a link both ways (the record id in the file and the commit SHA in the record).
|
|
93
93
|
- **Higher risk (engineering lead):** decide which changes count as higher risk. A tech lead or architect approves their specs and plans.
|
|
94
94
|
- **Policies (each policy owner, with an engineer):** the plugin ships a default for each area, `plugins/sdlc/claude-code/skills/<area>-policy/SKILL.md` (security: OWASP ASVS 5.0.0 Level 1; UX: WCAG 2.2 Level AA; compliance: the OECD privacy principles; brand: one declared source for every brand value). Review it. To replace a default, add the organization's own rules in `.agents/skills/<area>-policy/SKILL.md` in the project: that file wins over the plugin's default, and the `CODEOWNERS` line of step 2 for that area makes the policy owner its code owner. Do not edit the plugin's copy. Then run `pnpm rules:sync`, which renders the project's own policy skill for Claude Code and Cursor. Test that the skill triggers: ask for the relevant task in different ways and confirm the skill loads each time.
|
|
95
|
-
- **Production gate (engineering leadership, with change management and compliance):** name the release managers and add them as required reviewers on the `prod` environment, as the README's deploy section describes. `.agents/hooks/agent-production-gate.sh` blocks an agent's shell commands that dispatch a `prod` deploy, run a Railway command with `prod` as its environment, or push a release tag. It does not read MCP tools. A release manager whose own agent session must do one of these starts the agent with `RELEASE_APPROVAL` set to the approval record, for example `RELEASE_APPROVAL=CHG-123 claude`. The value lifts the gate only when it matches the extended regular expression on the first line of `.agents/release-approval-form`, shipped as `^[A-Z]+-[0-9]+$`; set that line to the form of your organization's approval records. Each gate hook appends its decisions to `.agents/logs/gate-decisions.jsonl`, which git ignores: the time, the hook, the decision, and the reason for a block, and, when the production gate is lifted, the approval value and the Git user name.
|
|
95
|
+
- **Production gate (engineering leadership, with change management and compliance):** name the release managers and add them as required reviewers on the `prod` environment, as the README's deploy section describes. `.agents/hooks/agent-production-gate.sh` blocks an agent's shell commands that dispatch a `prod` deploy, run a Railway command with `prod` as its environment, or push a release tag. It does not read MCP tools. A release manager whose own agent session must do one of these starts the agent with `RELEASE_APPROVAL` set to the approval record, for example `RELEASE_APPROVAL=CHG-123 claude`. The value lifts the gate only when it matches the extended regular expression on the first line of `.agents/release-approval-form`, shipped as `^[A-Z]+-[0-9]+$`; set that line to the form of your organization's approval records. Each gate hook appends its decisions to `.agents/logs/gate-decisions.jsonl`, which git ignores: the time, the hook, the decision, the session, and the reason for a block, and, when the production gate is lifted, the approval value and the Git user name. A record holds no command text and no path.
|
|
96
96
|
- **Review (tech lead):** `.agents/review.md` names this stack's generated files to skip; add a path when the project gains a generated file, then run `pnpm rules:sync`, which renders it to `REVIEW.md` for Claude Code Review. Once a month, rate the review findings and tune `.agents/review.md`.
|
|
97
97
|
- **People without Git experience:** connect GitHub to Claude (claude.ai or Cowork), so Claude commits `intent.md` for them.
|
|
98
98
|
|
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
import {execFileSync, spawnSync} from "node:child_process";
|
|
2
|
-
import {mkdtempSync, rmSync, writeFileSync} from "node:fs";
|
|
2
|
+
import {existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync} from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import {tmpdir} from "node:os";
|
|
5
5
|
|
|
6
6
|
type GateCase = {
|
|
7
|
+
expectedApproval?: string;
|
|
7
8
|
expectedExit: 0 | 2;
|
|
8
9
|
expectedPermission?: "allow";
|
|
10
|
+
expectedSession?: string;
|
|
9
11
|
gate: string;
|
|
10
12
|
input: Record<string, unknown>;
|
|
13
|
+
isRecordUnwritable?: boolean;
|
|
11
14
|
name: string;
|
|
12
15
|
projectDirectory?: string;
|
|
13
16
|
releaseApproval?: string;
|
|
@@ -31,6 +34,7 @@ const mcpGate = ".agents/hooks/agent-mcp-gate.sh";
|
|
|
31
34
|
const productionGate = ".agents/hooks/agent-production-gate.sh";
|
|
32
35
|
const testEditGate = ".agents/hooks/agent-test-edit-gate.sh";
|
|
33
36
|
const sourceFile = "src/example.ts";
|
|
37
|
+
const statusCommand = "git status";
|
|
34
38
|
|
|
35
39
|
const blockedCommands = [
|
|
36
40
|
"printf 'exit 0' > .husky/pre-commit",
|
|
@@ -109,7 +113,7 @@ const blockedCommands = [
|
|
|
109
113
|
|
|
110
114
|
const allowedCommands = [
|
|
111
115
|
'git commit -m "add login"',
|
|
112
|
-
|
|
116
|
+
statusCommand,
|
|
113
117
|
"pnpm check",
|
|
114
118
|
"cat .husky/pre-commit",
|
|
115
119
|
"git diff .husky/pre-commit",
|
|
@@ -205,8 +209,22 @@ const productionCommands = [
|
|
|
205
209
|
"cd a\\\\\ngit push origin v1.0.0",
|
|
206
210
|
];
|
|
207
211
|
|
|
208
|
-
const approvalLiftsGate = ["CHG-123", "
|
|
209
|
-
|
|
212
|
+
const approvalLiftsGate = ["CHG-123", "CHG-1"];
|
|
213
|
+
|
|
214
|
+
const approvalKeepsGate = [
|
|
215
|
+
"",
|
|
216
|
+
"0",
|
|
217
|
+
"false",
|
|
218
|
+
"no",
|
|
219
|
+
"FALSE",
|
|
220
|
+
"No",
|
|
221
|
+
"nO",
|
|
222
|
+
"1",
|
|
223
|
+
"off",
|
|
224
|
+
"00",
|
|
225
|
+
" ",
|
|
226
|
+
"chg-123",
|
|
227
|
+
];
|
|
210
228
|
|
|
211
229
|
const nonProductionCommands = [
|
|
212
230
|
"gh workflow run deploy.yml -f environment=uat -f commit_sha=abc",
|
|
@@ -440,6 +458,7 @@ const cases: GateCase[] = [
|
|
|
440
458
|
...approvalLiftsGate.map(releaseApproval => ({
|
|
441
459
|
gate: productionGate,
|
|
442
460
|
releaseApproval,
|
|
461
|
+
expectedApproval: releaseApproval,
|
|
443
462
|
input: {
|
|
444
463
|
tool_input: {
|
|
445
464
|
command: railwayProductionCommand,
|
|
@@ -554,12 +573,166 @@ const cases: GateCase[] = [
|
|
|
554
573
|
expectedExit: expectedExit as 0 | 2,
|
|
555
574
|
name: `test edit: ${name}`,
|
|
556
575
|
})),
|
|
576
|
+
{
|
|
577
|
+
gate: shellGate,
|
|
578
|
+
expectedPermission: "allow",
|
|
579
|
+
expectedSession: "claude-session",
|
|
580
|
+
name: "record: the session of a Claude Code hook input",
|
|
581
|
+
expectedExit: 0,
|
|
582
|
+
input: {
|
|
583
|
+
command: statusCommand,
|
|
584
|
+
session_id: "claude-session",
|
|
585
|
+
},
|
|
586
|
+
},
|
|
587
|
+
{
|
|
588
|
+
gate: productionGate,
|
|
589
|
+
expectedSession: "cursor-conversation",
|
|
590
|
+
name: "record: the conversation of a Cursor hook input",
|
|
591
|
+
expectedExit: 2,
|
|
592
|
+
input: {
|
|
593
|
+
command: railwayProductionCommand,
|
|
594
|
+
conversation_id: "cursor-conversation",
|
|
595
|
+
},
|
|
596
|
+
},
|
|
597
|
+
{
|
|
598
|
+
gate: shellGate,
|
|
599
|
+
name: "record: a blocked command that holds a secret",
|
|
600
|
+
expectedExit: 2,
|
|
601
|
+
input: {
|
|
602
|
+
command: "rm sk_live_SECRET123=.agents/hooks/agent-shell-gate.sh",
|
|
603
|
+
},
|
|
604
|
+
},
|
|
605
|
+
{
|
|
606
|
+
gate: constitutionGate,
|
|
607
|
+
name: "record: a blocked path that holds a secret",
|
|
608
|
+
expectedExit: 2,
|
|
609
|
+
input: {
|
|
610
|
+
file_path: ".agents/rules/sk_live_SECRET123.md",
|
|
611
|
+
},
|
|
612
|
+
},
|
|
613
|
+
{
|
|
614
|
+
gate: productionGate,
|
|
615
|
+
projectDirectory: outsideRepository,
|
|
616
|
+
name: "record: an approval in the form when the project has no form file",
|
|
617
|
+
releaseApproval: "CHG-123",
|
|
618
|
+
expectedExit: 2,
|
|
619
|
+
input: {
|
|
620
|
+
command: railwayProductionCommand,
|
|
621
|
+
},
|
|
622
|
+
},
|
|
623
|
+
{
|
|
624
|
+
gate: shellGate,
|
|
625
|
+
expectedPermission: "allow",
|
|
626
|
+
name: "record: an allow when the record cannot be written",
|
|
627
|
+
expectedExit: 0,
|
|
628
|
+
isRecordUnwritable: true,
|
|
629
|
+
input: {
|
|
630
|
+
command: statusCommand,
|
|
631
|
+
},
|
|
632
|
+
},
|
|
633
|
+
{
|
|
634
|
+
gate: productionGate,
|
|
635
|
+
name: "record: a block when the record cannot be written",
|
|
636
|
+
expectedExit: 2,
|
|
637
|
+
isRecordUnwritable: true,
|
|
638
|
+
input: {
|
|
639
|
+
command: railwayProductionCommand,
|
|
640
|
+
},
|
|
641
|
+
},
|
|
557
642
|
];
|
|
558
643
|
|
|
559
|
-
const
|
|
644
|
+
const recordDirectory = mkdtempSync(path.join(tmpdir(), "agent-gates-records-"));
|
|
645
|
+
const recordKeys = new Set(["approval", "decision", "hook", "reason", "session", "time", "user"]);
|
|
646
|
+
|
|
647
|
+
const gitUsername = spawnSync("git", ["config", "user.name"], {
|
|
648
|
+
encoding: "utf8",
|
|
649
|
+
}).stdout.trim();
|
|
650
|
+
|
|
651
|
+
const unwritableRecord = path.join(recordDirectory, "a-file");
|
|
652
|
+
|
|
653
|
+
writeFileSync(unwritableRecord, "");
|
|
654
|
+
|
|
655
|
+
/**
|
|
656
|
+
* Read the lines a gate appended to its record.
|
|
657
|
+
* @param recordPath The file the gate was told to append to.
|
|
658
|
+
* @returns The lines, or none when the gate wrote no file.
|
|
659
|
+
*/
|
|
660
|
+
function readRecordLines(recordPath: string): string[] {
|
|
661
|
+
return existsSync(recordPath) ? readFileSync(recordPath, "utf8").trim().split("\n") : [];
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
/**
|
|
665
|
+
* List the words of a hook input that look like a path, an assignment, or a file name.
|
|
666
|
+
* @param input The hook input.
|
|
667
|
+
* @returns Each such word of six characters or more.
|
|
668
|
+
*/
|
|
669
|
+
function listInputWords(input: Record<string, unknown>): string[] {
|
|
670
|
+
return JSON.stringify(input)
|
|
671
|
+
.split(/[\s"'\\{}:,]+/u)
|
|
672
|
+
.filter(word => word.length >= 6 && /[./=]/u.test(word));
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
/**
|
|
676
|
+
* Tell whether a gate wrote the record its decision calls for, and nothing a record must not hold.
|
|
677
|
+
* @param gateCase The case that ran.
|
|
678
|
+
* @param recordPath The file the gate was told to append to.
|
|
679
|
+
* @returns True when the record is missing, malformed, or wrong.
|
|
680
|
+
*/
|
|
681
|
+
function isRecordWrong(gateCase: GateCase, recordPath: string): boolean {
|
|
682
|
+
type DecisionRecord = {
|
|
683
|
+
approval?: string;
|
|
684
|
+
decision?: string;
|
|
685
|
+
hook?: string;
|
|
686
|
+
reason?: string;
|
|
687
|
+
session?: string;
|
|
688
|
+
time?: string;
|
|
689
|
+
user?: string;
|
|
690
|
+
};
|
|
691
|
+
|
|
692
|
+
const lines = readRecordLines(recordPath);
|
|
693
|
+
const lastLine = lines.at(-1) ?? "{}";
|
|
694
|
+
const record = JSON.parse(lastLine) as DecisionRecord;
|
|
695
|
+
const isBlock = gateCase.expectedExit === 2;
|
|
696
|
+
const isLifted = gateCase.expectedApproval !== undefined;
|
|
697
|
+
const secondsFromNow = Math.abs(Date.now() - Date.parse(record.time ?? "")) / 1000;
|
|
698
|
+
|
|
699
|
+
const found = [
|
|
700
|
+
record.decision,
|
|
701
|
+
record.hook,
|
|
702
|
+
record.session,
|
|
703
|
+
record.approval,
|
|
704
|
+
record.user,
|
|
705
|
+
record.reason !== undefined,
|
|
706
|
+
(record.time ?? "").endsWith("Z") && secondsFromNow < 600,
|
|
707
|
+
Object.keys(record).every(key => recordKeys.has(key)),
|
|
708
|
+
];
|
|
709
|
+
|
|
710
|
+
const expected = [
|
|
711
|
+
isBlock ? "block" : "allow",
|
|
712
|
+
path.basename(gateCase.gate, ".sh"),
|
|
713
|
+
gateCase.expectedSession,
|
|
714
|
+
gateCase.expectedApproval,
|
|
715
|
+
isLifted ? gitUsername : undefined,
|
|
716
|
+
isBlock,
|
|
717
|
+
true,
|
|
718
|
+
true,
|
|
719
|
+
];
|
|
720
|
+
|
|
721
|
+
const isHoldingInput = listInputWords(gateCase.input).some(word => lastLine.includes(word));
|
|
722
|
+
|
|
723
|
+
return lines.length !== 1 || JSON.stringify(found) !== JSON.stringify(expected) || isHoldingInput;
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
const failures = cases.filter((gateCase, index) => {
|
|
727
|
+
const recordPath =
|
|
728
|
+
gateCase.isRecordUnwritable === true
|
|
729
|
+
? path.join(unwritableRecord, "gate-decisions.jsonl")
|
|
730
|
+
: path.join(recordDirectory, `${index}.jsonl`);
|
|
731
|
+
|
|
560
732
|
const result = spawnSync(gateCase.gate, {
|
|
561
733
|
env: {
|
|
562
734
|
...childEnvironment,
|
|
735
|
+
AGENT_GATE_LOG: recordPath,
|
|
563
736
|
CLAUDE_PROJECT_DIR: gateCase.projectDirectory ?? process.cwd(),
|
|
564
737
|
CURSOR_PROJECT_DIR: gateCase.projectDirectory ?? process.cwd(),
|
|
565
738
|
RELEASE_APPROVAL: gateCase.releaseApproval,
|
|
@@ -572,10 +745,17 @@ const failures = cases.filter(gateCase => {
|
|
|
572
745
|
(JSON.parse(result.stdout.toString()) as {permission?: string}).permission !==
|
|
573
746
|
gateCase.expectedPermission;
|
|
574
747
|
|
|
575
|
-
|
|
748
|
+
const isRecordChecked = gateCase.isRecordUnwritable !== true;
|
|
749
|
+
|
|
750
|
+
return (
|
|
751
|
+
result.status !== gateCase.expectedExit ||
|
|
752
|
+
isPermissionWrong ||
|
|
753
|
+
(isRecordChecked && isRecordWrong(gateCase, recordPath))
|
|
754
|
+
);
|
|
576
755
|
});
|
|
577
756
|
|
|
578
757
|
for (const directory of [
|
|
758
|
+
recordDirectory,
|
|
579
759
|
fixBranchWithTest,
|
|
580
760
|
fixBranchWithoutTest,
|
|
581
761
|
featureBranchWithTest,
|