diff --git a/docs/architecture.md b/docs/architecture.md index 5344b391..96d7ec68 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -769,8 +769,11 @@ Model projection is separate from persistence. Trusted operator, model, and Go-c This design contains authority rather than attempting to classify every malicious sentence. A README can still contain false or contradictory facts, and a model can still reason badly about them, but document text cannot acquire Go capabilities or silently become system/assistant history. Policy, scope, approvals, budgets, leases, and Tool Gateway remain authoritative even if a model follows an indirect injection semantically. Schema v114 extends that boundary with three deliberately non-authorizing context layers. -`project_instruction_snapshot.v1` discovers bounded root-to-target `AGENTS.md`, -`CLAUDE.md`, and `.prayu` guidance, records path/hash/scope/trust/applicability and +`project_instruction_snapshot.v1` discovers bounded root-to-target guidance, selecting +non-empty `AGENTS.override.md` ahead of same-directory `AGENTS.md`, with fallback when +the override is absent, empty, or explicitly ignored. It also reads `CLAUDE.md`, +`.claude/CLAUDE.md`, and `.prayu` guidance, explains superseded sources in `ignored`, +records path/hash/scope/trust/applicability and why-effective metadata, and pins the full fingerprint to the Run. Live file drift is read-only until an operator confirms a diff bound to both the old pinned fingerprint and the reviewed live fingerprint, then appends a new diff --git a/docs/context-continuity.md b/docs/context-continuity.md index 22516938..0ed8f470 100644 --- a/docs/context-continuity.md +++ b/docs/context-continuity.md @@ -70,19 +70,21 @@ Tool、Network、Secret、Debug、Plugin、Hook、MCP、审批、Credential、 发现器只在已注册 Workspace 边界内读取: -- 每一级目录中的 `AGENTS.md` 与 `CLAUDE.md`; +- 每一级目录中优先读取非空 `AGENTS.override.md`;缺失、空白或显式忽略时回退到同目录 `AGENTS.md`; +- 每一级目录中的 `CLAUDE.md` 与 `.claude/CLAUDE.md`,后者使用所在项目目录的 scope; - 每一级目录中的 `.prayu/instructions.md`; - 每一级目录下 `.prayu/rules/**/*.md` 的稳定字典序集合; - Workspace 根的 `.prayu/instructions.ignore`,用于排除规范的相对路径 glob。 顺序从 Workspace 根到目标所在目录,越近的目录优先级越高;同一级依次为 -`AGENTS.md`、`CLAUDE.md`、针路簿 instructions、排序后的 rules。每个 source 保存相对 +所选 AGENTS 文件、`CLAUDE.md`、`.claude/CLAUDE.md`、针路簿 instructions、排序后的 rules。每个 source 保存相对 path、content SHA-256、scope、depth、precedence、加载时间、trust、适用 target、 -`why_effective`、脱敏状态和全部 false 的授权字段。冲突保留双方并解释最近目录获胜, -不会静默删除低优先级来源。 +`why_effective`、脱敏状态和全部 false 的授权字段。跨目录冲突保留双方并解释最近目录获胜; +同目录被覆盖的 `AGENTS.md` 记录在 `ignored` 中,说明对应的 `AGENTS.override.md` 来源。 边界为最多 64 个文件、单文件 64 KiB、总计 256 KiB、目标/规则深度 32、ignore -16 KiB/128 条。绝对路径、`..` 逃逸、NUL、无效 UTF-8、空文件、特殊文件、symlink、 +16 KiB/128 条。空白 `AGENTS.override.md` 记录跳过原因后回退;其他空文件仍被拒绝。 +绝对路径、`..` 逃逸、NUL、无效 UTF-8、特殊文件、symlink、 junction/reparse、并发修改、超限和非法 ignore 均 fail closed。Windows 使用大小写不敏感 的规范路径比较;Linux/macOS 保留大小写语义。内容在 hash 和交付前经过 Secret 脱敏。 @@ -255,14 +257,18 @@ leases, execution profiles, or policy overrides. ### Hierarchical discovery and immutable Run snapshots -Within the exact registered Workspace, Traverse Board discovers `AGENTS.md`, `CLAUDE.md`, -`.prayu/instructions.md`, and sorted `.prayu/rules/**/*.md` from root to the target directory. +Within the exact registered Workspace, Traverse Board selects a non-empty `AGENTS.override.md` +or falls back to `AGENTS.md` when the override is absent, empty, or explicitly ignored. It also +discovers `CLAUDE.md`, `.claude/CLAUDE.md`, `.prayu/instructions.md`, and sorted +`.prayu/rules/**/*.md` from root to the target directory, in that order at each level. +The hidden Claude file uses the scope of its containing project directory. Nearer directories have higher precedence. Each source retains its canonical relative path, content digest, scope, depth, precedence, load time, trust class, target applicability, -why-effective explanation, redaction state, and an all-false authority projection. Conflicts remain -visible rather than silently dropping the lower source. +why-effective explanation, redaction state, and an all-false authority projection. Cross-directory +conflicts retain both sources. A superseded same-directory `AGENTS.md` appears in `ignored` +with its overriding source identified. Empty overrides also retain a skip explanation. -Discovery fails closed on escapes, invalid UTF-8/NUL, special files, symlinks/reparse points, +Discovery fails closed on other empty sources, escapes, invalid UTF-8/NUL, special files, symlinks/reparse points, concurrent modification, malformed ignore rules, or the 64-file/64-KiB-each/256-KiB-total/32-depth bounds. Run creation pins the complete snapshot and fingerprint. Later disk drift produces an explainable diff but cannot change an active Run; an operator must confirm against the exact prior diff --git a/docs/project-instruction-delivery.md b/docs/project-instruction-delivery.md index 2550967a..53efef2c 100644 --- a/docs/project-instruction-delivery.md +++ b/docs/project-instruction-delivery.md @@ -9,6 +9,16 @@ sources. A required root or nested `AGENTS.md` is sent in full, with its origina source, scope, precedence and hashes. The application does not interpret natural language to find critical clauses, infer overrides or grant execution authority. +Discovery checks each directory from the Workspace root to the target. A non-empty +`AGENTS.override.md` replaces that directory's `AGENTS.md`; an absent, empty or +explicitly ignored override falls back to `AGENTS.md`. Other invalid override +files fail discovery. Superseded and skipped sources remain visible in the +discovery explanation. `CLAUDE.md` and `.claude/CLAUDE.md` are both included, +followed by `.prayu` instructions and rules. The hidden Claude file has the scope +of its containing project directory. All selected sources retain the existing +path, size, UTF-8, redaction and workflow-only authority checks. New discovery +results still require the existing confirmed refresh to replace a pinned Run. + ## Confirm a source classification Read `GET /api/v1/runs/{run_id}/project-instructions` first. Its `pinned` snapshot diff --git a/docs/usage.md b/docs/usage.md index d420e70a..621af7ca 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -1996,8 +1996,11 @@ hooks: ## Durable Context Continuity / 持久上下文连续性 -Schema v114 adds hierarchical `AGENTS.md`/`CLAUDE.md`/`.prayu` instruction discovery, -operator-explicit user/project memory, and a browsable Session checkpoint/Fork/Resume tree. +Project instruction discovery (schema v114) selects non-empty `AGENTS.override.md` +ahead of same-directory `AGENTS.md`, falling back when the override is absent, empty, +or explicitly ignored. It also reads `CLAUDE.md`, `.claude/CLAUDE.md`, and `.prayu` +guidance; superseded sources remain explained in `ignored`. The same context layer +provides operator-explicit user/project memory and a browsable Session checkpoint/Fork/Resume tree. 项目指令在 Run 创建时固定;磁盘漂移只有在显示 diff 并以旧 fingerprint 显式确认后才会 追加新 revision。长期记忆由操作者确认写入,支持编辑、禁用、保留期、导出和不可恢复的 物理删除。Schema v198 从带来源的决策/总结笔记生成待确认经验:在 **Context → 当前项目 diff --git a/internal/application/project_instruction_refresh_test.go b/internal/application/project_instruction_refresh_test.go index 834ebe28..da6e91b0 100644 --- a/internal/application/project_instruction_refresh_test.go +++ b/internal/application/project_instruction_refresh_test.go @@ -79,4 +79,28 @@ func TestRunPinsProjectInstructionsUntilConfirmedRefresh(t *testing.T) { if err != nil || len(history) != 2 || history[0].Revision != 2 || history[1].Revision != 1 { t.Fatalf("history=%#v err=%v", history, err) } + if err := os.WriteFile(filepath.Join(root, "AGENTS.override.md"), []byte("run override checks\n"), 0o600); err != nil { + t.Fatal(err) + } + service := application.NewProjectInstructionService(state) + preview, err := service.Inspect(ctx, run.ID, ".") + if err != nil || !preview.Stale || len(preview.Live.Sources) != 1 || + preview.Live.Sources[0].Content != "run override checks\n" || + preview.Pinned.Snapshot.Fingerprint != live.Fingerprint || + len(preview.Diff.Added) != 1 || len(preview.Diff.Removed) != 1 { + t.Fatalf("override did not produce a source replacement preview: %+v err=%v", preview, err) + } + unconfirmed, err := service.Refresh(ctx, run.ID, ".", live.Fingerprint, + preview.Live.Fingerprint, "desktop_operator", false) + if err != nil || unconfirmed.RefreshConfirmed || unconfirmed.Pinned.Revision != 2 || + unconfirmed.Pinned.Snapshot.Sources[0].Content != "run all tests\n" { + t.Fatalf("override bypassed confirmed refresh: %+v err=%v", unconfirmed, err) + } + confirmed, err := service.Refresh(ctx, run.ID, ".", live.Fingerprint, + preview.Live.Fingerprint, "desktop_operator", true) + if err != nil || !confirmed.RefreshConfirmed || confirmed.Pinned.Revision != 3 || + confirmed.Pinned.Snapshot.Fingerprint != preview.Live.Fingerprint || + confirmed.Pinned.Snapshot.Sources[0].Content != "run override checks\n" { + t.Fatalf("confirmed override was not pinned: %+v err=%v", confirmed, err) + } } diff --git a/internal/application/supervisor_project_instruction_delivery_test.go b/internal/application/supervisor_project_instruction_delivery_test.go index 1a0560fa..a09145ec 100644 --- a/internal/application/supervisor_project_instruction_delivery_test.go +++ b/internal/application/supervisor_project_instruction_delivery_test.go @@ -100,6 +100,74 @@ func TestSupervisorProjectInstructionDeliveryPreservesRequiredOutboundContentUnd } } +func TestSupervisorProjectInstructionDeliverySupportsNativeInstructionPaths(t *testing.T) { + st, err := store.Open(filepath.Join(t.TempDir(), "native-instructions.db")) + if err != nil { + t.Fatal(err) + } + defer st.Close() + run, snapshot := newDeliveryTestRun(t, st, t.TempDir(), ".", map[string]string{ + "AGENTS.md": "SUPERSEDED_AGENT_RULE", + "AGENTS.override.md": "REQUIRED_AGENT_OVERRIDE", + ".claude/CLAUDE.md": "REQUIRED_CLAUDE_DIRECTORY_RULE", + }, map[string]projectconfig.InstructionRequirement{ + "AGENTS.override.md": projectconfig.InstructionMandatory, + ".claude/CLAUDE.md": projectconfig.InstructionMandatory, + }, false) + provider := &scriptedToolProvider{responses: []*llm.ChatResponse{ + textResponse(rootActionResponse(domain.RootActionContinue, "native project guidance delivered", "", "")), + }} + if _, err := newToolLoopSupervisor(st, provider).Step(t.Context(), run.ID); err != nil { + t.Fatal(err) + } + requests := provider.Requests() + if len(requests) != 1 || len(snapshot.Sources) != 2 { + t.Fatalf("unexpected native instruction delivery: requests=%d sources=%+v", len(requests), snapshot.Sources) + } + assertRequiredDeliveryRequest(t, requests[0], snapshot) + for _, message := range requests[0].Messages { + if strings.Contains(message.Content, "SUPERSEDED_AGENT_RULE") { + t.Fatal("same-directory AGENTS.md was delivered despite the override") + } + } +} + +func TestSupervisorProjectInstructionDeliveryWithClaudeDirectoryTarget(t *testing.T) { + for _, target := range []string{".claude/CLAUDE.md", ".claude/rules/nested/file.go"} { + t.Run(target, func(t *testing.T) { + st, err := store.Open(filepath.Join(t.TempDir(), "claude-target.db")) + if err != nil { + t.Fatal(err) + } + defer st.Close() + run, snapshot := newDeliveryTestRun(t, st, t.TempDir(), target, map[string]string{ + ".claude/CLAUDE.md": "REQUIRED_CLAUDE_CONTAINING_SCOPE", + ".claude/rules/nested/file.go": "package fixture\n", + }, map[string]projectconfig.InstructionRequirement{ + ".claude/CLAUDE.md": projectconfig.InstructionMandatory, + }, false) + if len(snapshot.Sources) != 1 || snapshot.Sources[0].Scope != "" || snapshot.Sources[0].Depth != 0 { + t.Fatalf("hidden instruction lost its containing scope: %+v", snapshot.Sources) + } + inspection, err := application.NewProjectInstructionService(st).Inspect(t.Context(), run.ID, "") + if err != nil || inspection.Stale || inspection.Live.Fingerprint != snapshot.Fingerprint { + t.Fatalf("pinned hidden target could not be inspected: %+v err=%v", inspection, err) + } + provider := &scriptedToolProvider{responses: []*llm.ChatResponse{ + textResponse(rootActionResponse(domain.RootActionContinue, "hidden target guidance delivered", "", "")), + }} + if _, err := newToolLoopSupervisor(st, provider).Step(t.Context(), run.ID); err != nil { + t.Fatal(err) + } + requests := provider.Requests() + if len(requests) != 1 { + t.Fatalf("provider requests=%d want=1", len(requests)) + } + assertRequiredDeliveryRequest(t, requests[0], snapshot) + }) + } +} + func TestSupervisorProjectInstructionDeliveryOmitsLargeEscapedExcludedSource(t *testing.T) { st, err := store.Open(filepath.Join(t.TempDir(), "excluded-escaped-bound.db")) if err != nil { diff --git a/internal/projectconfig/instructions.go b/internal/projectconfig/instructions.go index de2f0ca4..a0904f1d 100644 --- a/internal/projectconfig/instructions.go +++ b/internal/projectconfig/instructions.go @@ -34,7 +34,7 @@ const ( MaxInstructionIgnorePatterns = 128 ) -var instructionFileNames = []string{"AGENTS.md", "CLAUDE.md"} +var errEmptyInstruction = errors.New("instruction source cannot be empty after normalization") // InstructionAuthority is deliberately incapable of representing a grant. // Project-owned text can suggest workflow, formatting, and validation only. @@ -172,6 +172,7 @@ func DiscoverInstructions(ctx context.Context, workspaceRoot, targetPath string) snapshot.Ignored = append(snapshot.Ignored, *ignoreRecord) } totalBytes := 0 + seenCandidates := make(map[string]struct{}) for depth, directory := range directories { if err := ctx.Err(); err != nil { return InstructionSnapshot{}, err @@ -180,12 +181,30 @@ func DiscoverInstructions(ctx context.Context, workspaceRoot, targetPath string) if err != nil { return InstructionSnapshot{}, err } + agentsOverride := "" for _, candidate := range candidates { relative, err := filepath.Rel(root, candidate.path) if err != nil || escapesInstructionRoot(relative) { return InstructionSnapshot{}, errors.New("project instruction candidate escapes the workspace boundary") } relative = canonicalInstructionPath(relative) + // Hidden guidance can be encountered again as a direct file when + // the target enters its directory. Keep its first containing scope, + // and count or explain the same canonical path only once. + if _, seen := seenCandidates[relative]; seen { + continue + } + seenCandidates[relative] = struct{}{} + if candidate.kind == "agents" && agentsOverride != "" { + if _, err := os.Lstat(candidate.path); err == nil { + snapshot.Ignored = append(snapshot.Ignored, IgnoredInstruction{ + Path: relative, Reason: "superseded by " + agentsOverride, + }) + } else if !errors.Is(err, fs.ErrNotExist) { + return InstructionSnapshot{}, fmt.Errorf("project instruction %s: %w", relative, err) + } + continue + } if ignoredInstructionPath(relative, patterns) { snapshot.Ignored = append(snapshot.Ignored, IgnoredInstruction{ Path: relative, Reason: "matched .prayu/instructions.ignore", @@ -193,12 +212,21 @@ func DiscoverInstructions(ctx context.Context, workspaceRoot, targetPath string) continue } content, digest, redacted, found, err := readStableInstruction(ctx, candidate.path) + if candidate.agentsOverride && errors.Is(err, errEmptyInstruction) { + snapshot.Ignored = append(snapshot.Ignored, IgnoredInstruction{ + Path: relative, Reason: "empty AGENTS.override.md; falling back to AGENTS.md", + }) + continue + } if err != nil { return InstructionSnapshot{}, fmt.Errorf("project instruction %s: %w", relative, err) } if !found { continue } + if candidate.agentsOverride { + agentsOverride = relative + } if len(snapshot.Sources) >= MaxInstructionFiles { return InstructionSnapshot{}, fmt.Errorf("project instruction count exceeds %d", MaxInstructionFiles) } @@ -331,8 +359,9 @@ func DiffInstructionSnapshots(before, after InstructionSnapshot) InstructionSnap } type instructionCandidate struct { - path string - kind string + path string + kind string + agentsOverride bool } func instructionBoundary(workspaceRoot, targetPath string) (string, string, string, string, error) { @@ -439,11 +468,25 @@ func instructionDirectories(root, targetDir string) ([]string, error) { } func instructionCandidates(directory, root string) ([]instructionCandidate, error) { - candidates := make([]instructionCandidate, 0, 4) - for _, name := range instructionFileNames { + candidates := []instructionCandidate{ + {path: filepath.Join(directory, "AGENTS.override.md"), kind: "agents", agentsOverride: true}, + {path: filepath.Join(directory, "AGENTS.md"), kind: "agents"}, + {path: filepath.Join(directory, "CLAUDE.md"), kind: "claude"}, + } + claudeDir := filepath.Join(directory, ".claude") + if info, err := os.Lstat(claudeDir); err == nil { + if info.Mode()&fs.ModeSymlink != 0 || !info.IsDir() { + return nil, errors.New("project instruction .claude path must be a real directory") + } + resolved, err := filepath.EvalSymlinks(claudeDir) + if err != nil || !sameInstructionPath(claudeDir, resolved) { + return nil, errors.New("project instruction .claude path cannot contain symlink or junction indirection") + } candidates = append(candidates, instructionCandidate{ - path: filepath.Join(directory, name), kind: strings.TrimSuffix(strings.ToLower(name), ".md"), + path: filepath.Join(claudeDir, "CLAUDE.md"), kind: "claude", }) + } else if !errors.Is(err, fs.ErrNotExist) { + return nil, err } prayuDir := filepath.Join(directory, ConfigDirName) if info, err := os.Lstat(prayuDir); err == nil { @@ -529,7 +572,7 @@ func readStableInstruction(ctx context.Context, filename string) (string, string if before.Mode()&fs.ModeSymlink != 0 || !before.Mode().IsRegular() { return "", "", false, false, errors.New("instruction source must be a regular file without symlink or junction indirection") } - if before.Size() <= 0 || before.Size() > MaxInstructionFileBytes { + if before.Size() > MaxInstructionFileBytes { return "", "", false, false, fmt.Errorf("instruction source must contain between 1 and %d bytes", MaxInstructionFileBytes) } read := func() ([]byte, error) { @@ -554,12 +597,12 @@ func readStableInstruction(ctx context.Context, filename string) (string, string if err != nil || !stableInstructionBytes(first, second) { return "", "", false, false, errors.New("instruction source changed concurrently") } - if len(first) == 0 || len(first) > MaxInstructionFileBytes || !utf8.Valid(first) || bytes.IndexByte(first, 0) >= 0 { + if len(first) > MaxInstructionFileBytes || !utf8.Valid(first) || bytes.IndexByte(first, 0) >= 0 { return "", "", false, false, errors.New("instruction source must be bounded UTF-8 without NUL bytes") } content := normalizeInstructionContent(string(first)) if content == "" { - return "", "", false, false, errors.New("instruction source cannot be empty after normalization") + return "", "", false, false, errEmptyInstruction } safe := redact.String(content) redacted := safe != content diff --git a/internal/projectconfig/instructions_claude_target_test.go b/internal/projectconfig/instructions_claude_target_test.go new file mode 100644 index 00000000..2e77b0c2 --- /dev/null +++ b/internal/projectconfig/instructions_claude_target_test.go @@ -0,0 +1,87 @@ +package projectconfig + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestDiscoverInstructionsClaudeDirectoryTargetsKeepContainingScope(t *testing.T) { + for _, scope := range []string{"", "src", ".claude/rules"} { + for _, suffix := range []string{"CLAUDE.md", ".", "rules", "rules/nested"} { + target := filepath.Join(scope, ".claude", suffix) + t.Run(filepath.ToSlash(target), func(t *testing.T) { + root := t.TempDir() + writeInstruction(t, root, "CLAUDE.md", "root guidance\n") + writeInstruction(t, root, ".claude/CLAUDE.md", "root hidden guidance\n") + hidden := filepath.Join(scope, ".claude", "CLAUDE.md") + writeInstruction(t, root, hidden, "containing scope guidance\n") + if suffix != "CLAUDE.md" { + if err := os.MkdirAll(filepath.Join(root, target), 0o700); err != nil { + t.Fatal(err) + } + } + snapshot, err := DiscoverInstructions(t.Context(), root, target) + if err != nil { + t.Fatal(err) + } + wantPaths := []string{"CLAUDE.md", ".claude/CLAUDE.md"} + if scope != "" { + wantPaths = append(wantPaths, hidden) + } + if len(snapshot.Sources) != len(wantPaths) { + t.Fatalf("sources=%+v want paths=%v", snapshot.Sources, wantPaths) + } + for index, source := range snapshot.Sources { + wantScope, wantDepth := "", 0 + if index == 2 { + wantScope = canonicalInstructionPath(scope) + wantDepth = len(strings.Split(filepath.ToSlash(scope), "/")) + } + if source.Path != canonicalInstructionPath(wantPaths[index]) || + source.Scope != wantScope || source.Depth != wantDepth || + source.Ordinal != index+1 || source.Precedence != 100+index { + t.Fatalf("source lost its containing scope or order: %+v", source) + } + } + again, err := DiscoverInstructions(t.Context(), root, target) + if err != nil || again.Fingerprint != snapshot.Fingerprint { + t.Fatalf("repeated discovery changed: %v", err) + } + }) + } + } +} + +func TestDiscoverInstructionsClaudeDirectoryTargetRecordsIgnoreOnce(t *testing.T) { + root := t.TempDir() + writeInstruction(t, root, "AGENTS.md", "retained guidance\n") + writeInstruction(t, root, ".claude/CLAUDE.md", "ignored guidance\n") + writeInstruction(t, root, ".prayu/instructions.ignore", ".claude/CLAUDE.md\n") + snapshot, err := DiscoverInstructions(t.Context(), root, ".claude/CLAUDE.md") + if err != nil { + t.Fatal(err) + } + if len(snapshot.Sources) != 1 || snapshot.Sources[0].Path != canonicalInstructionPath("AGENTS.md") || + len(snapshot.Ignored) != 2 || snapshot.Ignored[1].Path != canonicalInstructionPath(".claude/CLAUDE.md") { + t.Fatalf("ignored guidance was repeated or delivered: sources=%+v ignored=%+v", snapshot.Sources, snapshot.Ignored) + } +} + +func TestDiscoverInstructionsClaudeDirectoryTargetCountsUniqueFiles(t *testing.T) { + root := t.TempDir() + writeInstruction(t, root, ".claude/CLAUDE.md", "hidden guidance\n") + for index := 0; index < MaxInstructionFiles-1; index++ { + writeInstruction(t, root, fmt.Sprintf(".prayu/rules/%02d.md", index), "root rule\n") + } + snapshot, err := DiscoverInstructions(t.Context(), root, ".claude/CLAUDE.md") + if err != nil || len(snapshot.Sources) != MaxInstructionFiles { + t.Fatalf("unique files at the limit were rejected: sources=%d err=%v", len(snapshot.Sources), err) + } + writeInstruction(t, root, ".claude/AGENTS.md", "one extra source\n") + if _, err := DiscoverInstructions(t.Context(), root, ".claude/CLAUDE.md"); err == nil || !strings.Contains(err.Error(), "instruction count exceeds") { + t.Fatalf("a distinct source bypassed the file limit: %v", err) + } +} diff --git a/internal/projectconfig/instructions_test.go b/internal/projectconfig/instructions_test.go index 2e5a9368..bd97b9be 100644 --- a/internal/projectconfig/instructions_test.go +++ b/internal/projectconfig/instructions_test.go @@ -73,6 +73,167 @@ func TestDiscoverInstructionsIsHierarchicalDeterministicAndExplainable(t *testin } } +func TestDiscoverInstructionsSelectsAgentOverridesPerDirectory(t *testing.T) { + root := t.TempDir() + writeInstruction(t, root, "AGENTS.md", "superseded root workflow\n") + writeInstruction(t, root, "AGENTS.override.md", "root override\n") + writeInstruction(t, root, "CLAUDE.md", "shared Claude guidance\n") + writeInstruction(t, root, "src/AGENTS.md", "source workflow\n") + writeInstruction(t, root, "src/parser/AGENTS.md", strings.Repeat("x", MaxInstructionFileBytes+1)) + writeInstruction(t, root, "src/parser/AGENTS.override.md", "parser override\n") + + snapshot, err := DiscoverInstructions(t.Context(), root, "src/parser") + if err != nil { + t.Fatal(err) + } + want := []string{"AGENTS.override.md", "CLAUDE.md", "src/AGENTS.md", "src/parser/AGENTS.override.md"} + if len(snapshot.Sources) != len(want) { + t.Fatalf("unexpected instruction sources: %+v", snapshot.Sources) + } + for index, source := range snapshot.Sources { + if source.Path != canonicalInstructionPath(want[index]) || source.Ordinal != index+1 || + source.Authority != workflowOnlyInstructionAuthority() || source.Trust != InstructionTrustClass { + t.Fatalf("source order or authority changed: %+v", source) + } + if index != 1 && source.Kind != "agents" { + t.Fatalf("override no longer shares inherited AGENTS precedence: %+v", source) + } + } + if len(snapshot.Ignored) != 2 || + snapshot.Ignored[0].Path != canonicalInstructionPath("AGENTS.md") || + snapshot.Ignored[1].Path != canonicalInstructionPath("src/parser/AGENTS.md") || + !strings.Contains(snapshot.Ignored[1].Reason, canonicalInstructionPath("src/parser/AGENTS.override.md")) { + t.Fatalf("superseded sources lack an explanation: %+v", snapshot.Ignored) + } + if len(snapshot.Conflicts) != 2 || snapshot.Conflicts[1].HigherPrecedencePath != canonicalInstructionPath(want[3]) { + t.Fatalf("nested override lost inherited precedence: %+v", snapshot.Conflicts) + } + again, err := DiscoverInstructions(t.Context(), root, "src/parser") + if err != nil || snapshot.Fingerprint != again.Fingerprint { + t.Fatalf("unchanged override discovery is not stable: %v", err) + } +} + +func TestDiscoverInstructionsFallsBackFromAbsentEmptyOrIgnoredAgentOverride(t *testing.T) { + for _, test := range []struct { + name string + content string + absent bool + ignored bool + }{ + {name: "absent", absent: true}, + {name: "empty"}, + {name: "whitespace", content: "\ufeff\r\n \t\n"}, + {name: "ignored", content: "ignored override\n", ignored: true}, + } { + t.Run(test.name, func(t *testing.T) { + root := t.TempDir() + writeInstruction(t, root, "AGENTS.md", "fallback guidance\n") + if !test.absent { + writeInstruction(t, root, "AGENTS.override.md", test.content) + } + if test.ignored { + writeInstruction(t, root, ".prayu/instructions.ignore", "AGENTS.override.md\n") + } + snapshot, err := DiscoverInstructions(t.Context(), root, ".") + if err != nil || len(snapshot.Sources) != 1 || + snapshot.Sources[0].Path != canonicalInstructionPath("AGENTS.md") || + snapshot.Sources[0].Content != "fallback guidance\n" { + t.Fatalf("fallback guidance was lost: %+v err=%v", snapshot.Sources, err) + } + if !test.absent && len(snapshot.Ignored) == 0 { + t.Fatal("skipped override has no discovery explanation") + } + }) + } +} + +func TestDiscoverInstructionsIncludesClaudeDirectoryAtContainingScope(t *testing.T) { + root := t.TempDir() + writeInstruction(t, root, "CLAUDE.md", "root guidance\n") + writeInstruction(t, root, ".claude/CLAUDE.md", "token=ghp_abcdefghijklmnopqrstuvwxyz1234567890\n") + writeInstruction(t, root, "src/.claude/CLAUDE.md", "source guidance\n") + snapshot, err := DiscoverInstructions(t.Context(), root, "src") + if err != nil { + t.Fatal(err) + } + want := []string{"CLAUDE.md", ".claude/CLAUDE.md", "src/.claude/CLAUDE.md"} + if len(snapshot.Sources) != len(want) { + t.Fatalf("missing Claude directory guidance: %+v", snapshot.Sources) + } + for index, source := range snapshot.Sources { + if source.Path != canonicalInstructionPath(want[index]) || source.Kind != "claude" || + source.Authority != workflowOnlyInstructionAuthority() || source.Trust != InstructionTrustClass { + t.Fatalf("unexpected Claude source metadata: %+v", source) + } + } + if snapshot.Sources[1].Scope != "" || snapshot.Sources[1].Depth != 0 || + snapshot.Sources[2].Scope != "src" || snapshot.Sources[2].Depth != 1 || + !snapshot.Sources[1].Redacted || strings.Contains(snapshot.Sources[1].Content, "ghp_") { + t.Fatalf("hidden directory changed scope or retained a secret: %+v", snapshot.Sources) + } + if len(snapshot.Conflicts) != 1 || snapshot.Conflicts[0].HigherPrecedencePath != canonicalInstructionPath(want[2]) { + t.Fatalf("nested Claude directory lost precedence: %+v", snapshot.Conflicts) + } +} + +func TestDiscoverInstructionsNativePathsRetainSourceValidation(t *testing.T) { + for _, name := range []string{"AGENTS.override.md", ".claude/CLAUDE.md"} { + t.Run(name, func(t *testing.T) { + for _, test := range []struct{ name, content string }{ + {"oversized", strings.Repeat("x", MaxInstructionFileBytes+1)}, + {"invalid_utf8", string([]byte{0xff})}, + {"nul", "unsafe\x00guidance"}, + } { + t.Run(test.name, func(t *testing.T) { + root := t.TempDir() + writeInstruction(t, root, "AGENTS.md", "valid fallback\n") + writeInstruction(t, root, name, test.content) + if _, err := DiscoverInstructions(t.Context(), root, "."); err == nil { + t.Fatal("invalid native instruction was accepted or silently skipped") + } + }) + } + }) + } + for _, name := range []string{"AGENTS.md", "CLAUDE.md", ".claude/CLAUDE.md", ".prayu/instructions.md"} { + t.Run("empty_"+name, func(t *testing.T) { + root := t.TempDir() + writeInstruction(t, root, name, "") + if _, err := DiscoverInstructions(t.Context(), root, "."); err == nil { + t.Fatal("empty-source exception escaped AGENTS.override.md") + } + }) + } +} + +func TestDiscoverInstructionsRejectsNativePathIndirection(t *testing.T) { + for _, name := range []string{"AGENTS.override.md", ".claude/CLAUDE.md", ".claude"} { + t.Run(name, func(t *testing.T) { + root, outside := t.TempDir(), t.TempDir() + writeInstruction(t, root, "AGENTS.md", "valid fallback\n") + writeInstruction(t, outside, "CLAUDE.md", "outside guidance\n") + target := filepath.Join(outside, "CLAUDE.md") + if name == ".claude" { + target = outside + } + link := filepath.Join(root, filepath.FromSlash(name)) + if err := os.MkdirAll(filepath.Dir(link), 0o700); err != nil { + t.Fatal(err) + } + if err := os.Symlink(target, link); err != nil { + if runtime.GOOS == "windows" { + t.Skip("symlink creation is unavailable") + } + t.Fatal(err) + } + if _, err := DiscoverInstructions(t.Context(), root, "."); err == nil { + t.Fatal("symlinked native instruction was accepted") + } + }) + } +} + func TestDiscoverInstructionsManyRulesPreserveNestedPrecedence(t *testing.T) { for _, count := range []int{9, MaxInstructionFiles - 2} { t.Run(fmt.Sprint(count), func(t *testing.T) { diff --git a/internal/projectconfig/instructions_windows_test.go b/internal/projectconfig/instructions_windows_test.go new file mode 100644 index 00000000..30db8f60 --- /dev/null +++ b/internal/projectconfig/instructions_windows_test.go @@ -0,0 +1,34 @@ +//go:build windows + +package projectconfig + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "syscall" + "testing" + + "golang.org/x/sys/windows" +) + +func TestDiscoverInstructionsRejectsClaudeDirectoryJunction(t *testing.T) { + root, outside := t.TempDir(), t.TempDir() + writeInstruction(t, root, "AGENTS.md", "workspace guidance\n") + writeInstruction(t, outside, "CLAUDE.md", "outside guidance\n") + system, err := windows.GetSystemDirectory() + if err != nil { + t.Fatal(err) + } + link := filepath.Join(root, ".claude") + command := exec.Command(filepath.Join(system, "cmd.exe"), "/d", "/c", "mklink", "/J", link, outside) + command.SysProcAttr = &syscall.SysProcAttr{HideWindow: true, CreationFlags: windows.CREATE_NO_WINDOW} + if output, err := command.CombinedOutput(); err != nil { + t.Fatalf("temporary directory junction: %v %s", err, output) + } + t.Cleanup(func() { _ = os.Remove(link) }) + if _, err := DiscoverInstructions(t.Context(), root, "."); err == nil || !strings.Contains(err.Error(), ".claude") { + t.Fatalf("Claude instruction directory junction was not rejected: %v", err) + } +}