Skip to content

Commit 47f768e

Browse files
davidslaterGitHub AceGitHub Ace
authored
feat(artifacts): surface activation context and inventory (#747)
* feat(artifacts): surface activation context and inventory Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: David Slater <12449447+davidslater@users.noreply.github.com> * fix(stepsummary): harden inventory cell escaping against markdown injection Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: David Slater <12449447+davidslater@users.noreply.github.com> --------- Co-authored-by: GitHub Ace <githubnext@users.noreply.github.com> Co-authored-by: David Slater <12449447+davidslater@users.noreply.github.com> Co-authored-by: GitHub Ace <ace@githubnext.com>
1 parent 8850903 commit 47f768e

15 files changed

Lines changed: 686 additions & 22 deletions

File tree

‎CLAUDE.md‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -108,13 +108,17 @@ The detector reads the verdict exclusively from the out-of-band result sink. The
108108
│ ├── prompt-template.txt # optional, pre-expansion
109109
│ └── prompt-import-tree.json # optional, runtime-import provenance
110110
├── agent_output.json # required
111-
├── aw_info.json # optional, activation metadata
111+
├── aw_info.json # optional, bounded untrusted activation context
112112
├── aw-*.patch # optional, git format-patch
113113
├── aw-*.bundle # optional, git bundles
114-
├── experiments/ # optional
115-
└── comment-memory/*.md # optional
114+
├── experiments/ # optional, inventoried only
115+
└── comment-memory/*.md # optional, inventoried only
116116
```
117117

118+
All files are recursively inventoried in the JSONL run log and Actions step
119+
summary. The prompt consumes only an allowlisted, size-bounded subset of
120+
`aw_info.json`; unknown fields are ignored and all included values are untrusted.
121+
118122
## Detection Flow
119123

120124
- `pkg/detector/detector.go` (`BuildPrompt`) renders `prompts/threat_detection.md` with placeholders substituted from artifacts and `BuildPromptAnalysis` (untrusted-input breakdown).

‎DEVGUIDE.md‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,9 +89,14 @@ The detector operates on an artifacts directory. The current expected shape is d
8989

9090
- `aw-prompts/prompt.txt`
9191
- `agent_output.json`
92+
- optional `aw_info.json` (bounded allowlisted fields are consumed as untrusted context)
9293
- optional `aw-*.patch`
9394
- optional `aw-*.bundle`
94-
- optional `comment-memory/*.md`
95+
- optional `experiments/*` (inventoried only)
96+
- optional `comment-memory/*.md` (inventoried only)
97+
98+
Every non-directory entry is recursively inventoried with its size and consumed
99+
status for the run log and GitHub Actions step summary.
95100

96101
When changing artifact handling, review:
97102

‎README.md‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -234,14 +234,20 @@ removing quarantine.
234234
│ ├── prompt-template.txt # Pre-expansion prompt template (optional)
235235
│ └── prompt-import-tree.json # Runtime-import provenance (optional)
236236
├── agent_output.json # Agent structured output
237-
├── aw_info.json # Activation metadata (optional)
237+
├── aw_info.json # Bounded activation context (optional, consumed as untrusted)
238238
├── aw-*.patch # Git format-patch files (optional)
239239
├── aw-*.bundle # Git bundle files (optional)
240-
├── experiments/ # Experiment assignment/state files (optional)
241-
└── comment-memory/ # Agent comment memory (optional)
240+
├── experiments/ # Experiment assignment/state files (optional, inventoried only)
241+
└── comment-memory/ # Agent comment memory (optional, inventoried only)
242242
└── *.md
243243
```
244244

245+
Every file below the artifacts directory is recorded with its size and consumed
246+
status in the JSONL `artifacts_loaded` event and, when `GITHUB_STEP_SUMMARY` is
247+
set, in the Actions step summary. Only an allowlisted, size-bounded subset of
248+
`aw_info.json` is added to the detection prompt, and all of its values are
249+
explicitly treated as untrusted runtime data.
250+
245251
### Output (JSON)
246252

247253
```json

‎cmd/threat-detect/logfile_test.go‎

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,15 @@ func findRecord(records []map[string]any, event string) map[string]any {
4040

4141
func TestRunWritesJSONLLog(t *testing.T) {
4242
artifactsDir := t.TempDir()
43+
if err := os.MkdirAll(filepath.Join(artifactsDir, "experiments"), 0o755); err != nil {
44+
t.Fatal(err)
45+
}
46+
if err := os.WriteFile(filepath.Join(artifactsDir, "experiments", "assignment.json"), []byte("{}"), 0o600); err != nil {
47+
t.Fatal(err)
48+
}
4349
outputPath := filepath.Join(t.TempDir(), "result.json")
4450
logPath := filepath.Join(t.TempDir(), "run.jsonl")
51+
summaryPath := filepath.Join(t.TempDir(), "summary.md")
4552
copilotMarker := filepath.Join(t.TempDir(), "copilot-called")
4653
sinkJSON := `{"prompt_injection":true,"secret_leak":false,"malicious_patch":false,"reasons":["agentic detection"]}`
4754
fakeBinDir := writeFakeCopilotWithSink(t, copilotMarker, sinkJSON, 0)
@@ -52,7 +59,8 @@ func TestRunWritesJSONLLog(t *testing.T) {
5259
"-log-file", logPath,
5360
artifactsDir,
5461
}, map[string]string{
55-
"PATH": fakeBinDir + string(os.PathListSeparator) + os.Getenv("PATH"),
62+
"PATH": fakeBinDir + string(os.PathListSeparator) + os.Getenv("PATH"),
63+
"GITHUB_STEP_SUMMARY": summaryPath,
5664
})
5765

5866
if code != exitThreat {
@@ -71,6 +79,30 @@ func TestRunWritesJSONLLog(t *testing.T) {
7179
t.Errorf("run_start engine = %v, want copilot", start["engine"])
7280
}
7381

82+
loaded := findRecord(records, "artifacts_loaded")
83+
if loaded == nil {
84+
t.Fatalf("missing artifacts_loaded record: %#v", records)
85+
}
86+
inventory, ok := loaded["inventory"].([]any)
87+
if !ok || len(inventory) != 1 {
88+
t.Fatalf("artifacts_loaded inventory = %#v, want one entry", loaded["inventory"])
89+
}
90+
entry, ok := inventory[0].(map[string]any)
91+
if !ok {
92+
t.Fatalf("inventory entry = %#v", inventory[0])
93+
}
94+
if entry["path"] != "experiments/assignment.json" || entry["consumed"] != false {
95+
t.Errorf("inventory entry = %#v, want unconsumed experiment", entry)
96+
}
97+
98+
summary, err := os.ReadFile(summaryPath)
99+
if err != nil {
100+
t.Fatalf("reading step summary: %v", err)
101+
}
102+
if !strings.Contains(string(summary), "| <code>experiments/assignment.json</code> | 2 | file | No |") {
103+
t.Errorf("step summary missing experiment inventory:\n%s", summary)
104+
}
105+
74106
verdict := findRecord(records, "verdict")
75107
if verdict == nil {
76108
t.Fatalf("missing verdict record: %#v", records)
@@ -259,3 +291,31 @@ func TestRunRejectsUnopenableLogFile(t *testing.T) {
259291
t.Fatalf("stderr missing config_error status, got:\n%s", stderr)
260292
}
261293
}
294+
295+
func TestRunRejectsUnwritableStepSummary(t *testing.T) {
296+
artifactsDir := t.TempDir()
297+
logPath := filepath.Join(t.TempDir(), "run.jsonl")
298+
summaryPath := filepath.Join(t.TempDir(), "missing-dir", "summary.md")
299+
300+
code, stderr := runWithTestArgsCapture(t, []string{
301+
"threat-detect",
302+
"-log-file", logPath,
303+
artifactsDir,
304+
}, map[string]string{
305+
"GITHUB_STEP_SUMMARY": summaryPath,
306+
})
307+
308+
if code != exitError {
309+
t.Fatalf("run() exit code = %d, want %d", code, exitError)
310+
}
311+
if !strings.Contains(stderr, "Error writing artifact inventory summary") {
312+
t.Fatalf("stderr missing summary error, got:\n%s", stderr)
313+
}
314+
if !strings.Contains(stderr, "reason=config_error") {
315+
t.Fatalf("stderr missing config_error status, got:\n%s", stderr)
316+
}
317+
records := readJSONLRecords(t, logPath)
318+
if findRecord(records, "artifact_inventory_summary_failed") == nil {
319+
t.Fatalf("missing artifact_inventory_summary_failed record: %#v", records)
320+
}
321+
}

‎cmd/threat-detect/main.go‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import (
3030
"github.com/github/gh-aw-threat-detection/pkg/detector"
3131
"github.com/github/gh-aw-threat-detection/pkg/engine"
3232
"github.com/github/gh-aw-threat-detection/pkg/runlog"
33+
"github.com/github/gh-aw-threat-detection/pkg/stepsummary"
3334
)
3435

3536
const (
@@ -207,7 +208,16 @@ func run() (code int) {
207208
reason = reasonConfigError
208209
return exitError
209210
}
210-
logger.Info("artifacts_loaded", map[string]any{"artifacts_dir": artifactsDir})
211+
logger.Info("artifacts_loaded", map[string]any{
212+
"artifacts_dir": artifactsDir,
213+
"inventory": arts.Inventory,
214+
})
215+
if err := stepsummary.WriteArtifactInventory(os.Getenv("GITHUB_STEP_SUMMARY"), arts.Inventory); err != nil {
216+
fmt.Fprintf(os.Stderr, "Error writing artifact inventory summary: %v\n", err)
217+
logger.Error("artifact_inventory_summary_failed", map[string]any{"error": err.Error()})
218+
reason = reasonConfigError
219+
return exitError
220+
}
211221
for _, w := range arts.Warnings {
212222
fmt.Fprintf(os.Stderr, "::warning::%s\n", escapeWorkflowData(w))
213223
logger.Info("artifacts_warning", map[string]any{"warning": w})

0 commit comments

Comments
 (0)